From 743eb6a8e87c1f79b831e5ac563e73804370a3bf Mon Sep 17 00:00:00 2001 From: Hellings Date: Fri, 23 Oct 2026 08:53:07 -0400 Subject: [PATCH] Initial commit --- .gitignore | 42 + README.md | 7 + adapters/cloudflare-wrangler.ts | 53 + adapters/index.ts | 4 + adapters/prediction.ts | 442 ++++++ adapters/server-bun.ts | 123 ++ adapters/static.ts | 151 ++ adapters/vercel-vercel.ts | 68 + biome.json | 34 + bun.lock | 1389 +++++++++++++++++ cli.ts | 123 ++ contracts/index.ts | 229 +++ contracts/typegen/generator.ts | 427 +++++ contracts/typegen/http/headers.ts | 48 + contracts/typegen/http/index.ts | 5 + contracts/typegen/http/method.ts | 33 + contracts/typegen/http/request.ts | 90 ++ contracts/typegen/http/respcode.ts | 39 + contracts/typegen/http/response.ts | 52 + contracts/typegen/minify.ts | 45 + contracts/typegen/orm/field.ts | 80 + contracts/typegen/orm/index.ts | 3 + contracts/typegen/orm/repository.ts | 108 ++ contracts/typegen/orm/table.ts | 31 + contracts/typegen/orm/tables.ts | 4 + contracts/typegen/views.ts | 6 + core/app.ts | 201 +++ core/backport.ts | 497 ++++++ core/cache.ts | 55 + core/config.ts | 36 + core/container.ts | 307 ++++ core/content.ts | 163 ++ core/context.ts | 24 + core/errors.ts | 46 + core/form.ts | 145 ++ core/format-source.ts | 63 + core/format.ts | 22 + core/http.ts | 239 +++ core/index.ts | 7 + core/metadata.ts | 179 +++ core/modules.ts | 154 ++ core/paths.ts | 81 + core/pipeline.ts | 14 + core/router.ts | 522 +++++++ core/sessions.ts | 28 + core/templates.ts | 62 + ct.config.ts | 21 + decorators/Astro.ts | 8 + decorators/Auth.ts | 12 + decorators/Component.ts | 51 + decorators/Controller.ts | 59 + decorators/Inject.ts | 35 + decorators/Module.ts | 69 + decorators/Params.ts | 125 ++ decorators/RenderAhead.ts | 15 + decorators/RenderRoute.ts | 26 + decorators/Root.ts | 75 + decorators/Route.ts | 80 + decorators/Service.ts | 26 + decorators/View.ts | 83 + decorators/index.ts | 13 + editors/vscode/.vscodeignore | 2 + editors/vscode/bun.lock | 285 ++++ editors/vscode/language-configuration.json | 27 + .../vscode/muddle-language-tools-0.1.0.vsix | Bin 0 -> 13904 bytes editors/vscode/package.json | 69 + editors/vscode/snippets/spug.json | 18 + editors/vscode/snippets/stcss.json | 25 + editors/vscode/src/extension.js | 466 ++++++ editors/vscode/syntaxes/spug.tmLanguage.json | 200 +++ editors/vscode/syntaxes/stcss.tmLanguage.json | 251 +++ eslint.config.js | 42 + index.ts | 237 +++ integrations/auth.ts | 95 ++ integrations/fonts.ts | 65 + integrations/hooks.ts | 48 + integrations/optimize.ts | 1 + integrations/react.ts | 192 +++ integrations/responsivity.ts | 24 + integrations/shadcn.ts | 60 + justfile | 31 + modules/amezing/README.md | 3 + modules/amezing/components/alert.spug | 8 + modules/amezing/components/article-list.spug | 29 + modules/amezing/components/article-nav.spug | 14 + modules/amezing/components/article.spug | 31 + modules/amezing/components/audio-button.spug | 5 + modules/amezing/components/audio.spug | 7 + modules/amezing/components/banner.spug | 7 + modules/amezing/components/base.spug | 133 ++ modules/amezing/components/comments.spug | 32 + modules/amezing/components/crt.spug | 11 + modules/amezing/components/deanchor.spug | 1 + modules/amezing/components/emoji.spug | 5 + modules/amezing/components/footnotes.spug | 15 + modules/amezing/components/fuse.spug | 4 + modules/amezing/components/icon.spug | 3 + modules/amezing/components/image.spug | 22 + modules/amezing/components/link.spug | 14 + modules/amezing/components/mastodon.spug | 5 + modules/amezing/components/overflow-auto.spug | 9 + modules/amezing/components/paginator.spug | 32 + modules/amezing/components/search-fuse.spug | 10 + modules/amezing/components/search-modal.spug | 11 + .../amezing/components/skip-to-content.spug | 4 + modules/amezing/components/statement.spug | 11 + modules/amezing/components/tag-chips.spug | 9 + modules/amezing/components/video.spug | 21 + modules/amezing/components/vimeo.spug | 4 + modules/amezing/components/youtube.spug | 5 + modules/amezing/fonts/commissioner.woff2 | Bin 0 -> 268392 bytes modules/amezing/fonts/commit-mono.woff2 | Bin 0 -> 86768 bytes modules/amezing/index.ts | 161 ++ modules/amezing/package.json | 7 + modules/amezing/sass/amezing.scss | 7 + .../sass/citrine/abstracts/_functions.scss | 5 + .../sass/citrine/abstracts/_mixins.scss | 82 + .../sass/citrine/abstracts/_variables.scss | 570 +++++++ .../sass/citrine/base/_animations.scss | 83 + modules/amezing/sass/citrine/base/_base.scss | 59 + .../amezing/sass/citrine/base/_layout.scss | 36 + modules/amezing/sass/citrine/base/_reset.scss | 135 ++ modules/amezing/sass/citrine/base/_root.scss | 20 + .../sass/citrine/base/_typography.scss | 400 +++++ .../sass/citrine/components/_buttons.scss | 100 ++ .../sass/citrine/components/_card.scss | 5 + .../amezing/sass/citrine/components/_crt.scss | 90 ++ .../sass/citrine/components/_external.scss | 12 + .../sass/citrine/components/_iframe.scss | 20 + .../sass/citrine/components/_input.scss | 261 ++++ .../sass/citrine/components/_media.scss | 165 ++ .../citrine/components/_overflow-auto.scss | 13 + .../sass/citrine/components/_overshoot.scss | 23 + .../citrine/components/_skip-to-content.scss | 23 + .../sass/citrine/components/_spoiler.scss | 25 + .../sass/citrine/components/_table.scss | 36 + .../sass/citrine/components/_textarea.scss | 17 + .../sass/citrine/components/_title.scss | 5 + .../sass/citrine/components/_tooltip.scss | 41 + .../citrine/components/_visually-hidden.scss | 11 + modules/amezing/sass/citrine/style.scss | 29 + modules/amezing/sass/components/_alerts.scss | 153 ++ .../sass/components/_article-details.scss | 71 + .../sass/components/_article-list.scss | 254 +++ .../amezing/sass/components/_article-nav.scss | 66 + .../sass/components/_audio-button.scss | 42 + modules/amezing/sass/components/_audio.scss | 41 + modules/amezing/sass/components/_banner.scss | 98 ++ .../amezing/sass/components/_comments.scss | 459 ++++++ .../amezing/sass/components/_deanchor.scss | 20 + modules/amezing/sass/components/_emoji.scss | 21 + .../amezing/sass/components/_footnotes.scss | 62 + modules/amezing/sass/components/_fuse.scss | 50 + modules/amezing/sass/components/_giallo.scss | 16 + modules/amezing/sass/components/_icon.scss | 9 + modules/amezing/sass/components/_iframe.scss | 6 + modules/amezing/sass/components/_index.scss | 25 + modules/amezing/sass/components/_marquee.scss | 35 + .../amezing/sass/components/_not-found.scss | 11 + .../sass/components/_pre-container.scss | 141 ++ .../amezing/sass/components/_search-fuse.scss | 73 + .../sass/components/_search-modal.scss | 128 ++ .../sass/components/_skip-to-content.scss | 23 + .../amezing/sass/components/_statements.scss | 61 + .../amezing/sass/components/_tag-chips.scss | 59 + .../amezing/sass/components/_zola-anchor.scss | 50 + .../amezing/sass/components/_zola-error.scss | 55 + modules/amezing/sass/fonts.scss | 52 + modules/amezing/sass/layout/_footer.scss | 140 ++ modules/amezing/sass/layout/_full-bleed.scss | 13 + modules/amezing/sass/layout/_grid.scss | 16 + modules/amezing/sass/layout/_header.scss | 347 ++++ modules/amezing/sass/layout/_index.scss | 6 + .../amezing/sass/layout/_main-content.scss | 59 + modules/amezing/sass/layout/_sidebar.scss | 775 +++++++++ .../amezing/sass/mods/_compact-sidebar.scss | 44 + modules/amezing/sass/mods/_flat.scss | 27 + modules/amezing/sass/mods/_pill-tags.scss | 27 + modules/amezing/sass/mods/_skeuomorphic.scss | 30 + modules/amezing/sass/style.scss | 40 + modules/amezing/scripts/audio-button.js | 9 + modules/amezing/scripts/closable.js | 34 + modules/amezing/scripts/deanchor.js | 10 + modules/amezing/scripts/fuse.js | 9 + modules/amezing/scripts/marquee.js | 26 + modules/amezing/scripts/paginator.js | 23 + modules/amezing/scripts/search-fuse.js | 178 +++ modules/amezing/styles/ametrine.css | 1 + modules/amezing/styles/vars.css | 20 + modules/analysis/README.md | 1 + modules/analysis/index.ts | 56 + modules/analysis/inspect.ts | 75 + modules/analysis/package.json | 7 + modules/astro/index.ts | 80 + modules/astro/package.json | 14 + modules/database/index.ts | 2 + modules/database/package.json | 12 + modules/database/src/adapters/d1.ts | 213 +++ modules/database/src/adapters/driver.ts | 165 ++ modules/database/src/adapters/pg.ts | 118 ++ modules/database/src/adapters/sqlite.ts | 130 ++ modules/database/src/database.ts | 55 + modules/database/src/decorators/Field.ts | 40 + modules/database/src/decorators/Hydrate.ts | 42 + modules/database/src/decorators/Table.ts | 85 + modules/database/src/hydration.ts | 81 + modules/database/src/index.ts | 68 + modules/database/src/migrations.ts | 43 + modules/database/src/repository.ts | 281 ++++ modules/database/src/sessions.ts | 35 + modules/latex/index.ts | 39 + modules/latex/package.json | 12 + modules/mdx/index.ts | 14 + modules/mdx/package.json | 18 + modules/optimize/README.md | 1 + modules/optimize/index.ts | 88 ++ modules/optimize/package.json | 17 + modules/prettier-plugin/index.ts | 21 + modules/prettier-plugin/package.json | 7 + modules/shadcn/index.ts | 1 + modules/shadcn/package.json | 7 + modules/tailwind/index.ts | 62 + modules/tailwind/package.json | 12 + package.json | 61 + samples/hello-world/.gitignore | 3 + .../hello-world/_muddle/contracts/index.ts | 3 + .../hello-world/_muddle/contracts/minify.ts | 2 + .../_muddle/contracts/orm/tables.ts | 13 + .../hello-world/_muddle/contracts/views.ts | 16 + samples/hello-world/config.ts | 4 + samples/hello-world/ct.config.ts | 23 + samples/hello-world/index.ts | 46 + .../hello-world/modules/app/app.controller.ts | 53 + samples/hello-world/modules/app/app.module.ts | 9 + .../modules/app/asset.controller.ts | 5 + .../hello-world/modules/app/public/robots.txt | 2 + .../hello-world/modules/app/views/index.spug | 26 + .../hello-world/modules/app/views/layout.spug | 13 + .../hello-world/modules/app/views/post.spug | 31 + .../repositories/posts.repository.ts | 3 + samples/hello-world/styles/app.stcss | 28 + samples/hello-world/tsconfig.json | 10 + samples/react/.gitignore | 3 + samples/react/README.md | 1 + samples/react/_muddle/contracts/index.ts | 3 + samples/react/_muddle/contracts/minify.ts | 2 + samples/react/_muddle/contracts/orm/tables.ts | 13 + samples/react/_muddle/contracts/views.ts | 17 + samples/react/config.ts | 4 + samples/react/ct.config.ts | 17 + samples/react/index.ts | 51 + samples/react/modules/app/albumStyle.ts | 27 + samples/react/modules/app/app.controller.ts | 64 + samples/react/modules/app/app.module.ts | 8 + samples/react/modules/app/musicbrainz.ts | 184 +++ samples/react/modules/app/registry.ts | 78 + .../react/modules/app/storage/interpolate.txt | 1 + samples/react/modules/app/storage/predict.txt | 1 + samples/react/modules/app/storage/refer.txt | 1 + samples/react/modules/app/storage/route.txt | 1 + samples/react/modules/app/views/feature.spug | 21 + samples/react/modules/app/views/index.spug | 34 + samples/react/modules/app/views/layout.spug | 15 + samples/react/modules/app/views/navbar.spug | 34 + samples/react/styles/app.stcss | 717 +++++++++ samples/react/tsconfig.json | 9 + samples/shadcn-porter/config.ts | 3 + samples/shadcn-porter/ct.config.ts | 15 + .../dist.previous/_muddle/styles.css | 93 ++ .../shadcn-porter/dist.previous/index.html | 102 ++ samples/shadcn-porter/index.ts | 32 + .../shadcn-porter/modules/app/app.module.ts | 5 + .../modules/app/landing.controller.ts | 10 + .../modules/app/views/components/button.spug | 3 + .../modules/app/views/components/card.spug | 3 + .../modules/app/views/index.spug | 25 + samples/shadcn-porter/styles/landing.stcss | 97 ++ samples/shadcn-porter/tsconfig.json | 15 + tests/amezing.test.ts | 30 + tests/analysis.test.ts | 34 + tests/backport.test.ts | 118 ++ tests/components.test.ts | 21 + tests/core.test.ts | 151 ++ tests/css.test.ts | 263 ++++ tests/d1.test.ts | 66 + tests/hello-world.test.ts | 36 + tests/hooks.test.ts | 28 + tests/integrations.test.ts | 83 + tests/markdown.test.ts | 104 ++ tests/modules.test.ts | 61 + tests/orm.test.ts | 191 +++ tests/postgres.test.ts | 105 ++ tests/prediction.test.ts | 75 + tests/router.test.ts | 106 ++ tests/sessions-format.test.ts | 51 + tests/stage.test.ts | 22 + tests/templates-generator.test.ts | 29 + tests/templates.test.ts | 65 + tests/views.test.ts | 195 +++ tsconfig.json | 49 + views/api.ts | 303 ++++ views/components.ts | 79 + views/css/compiler.ts | 427 +++++ views/css/evaluate.ts | 318 ++++ views/css/index.ts | 43 + views/css/parser.ts | 381 +++++ views/css/sass.ts | 29 + views/engine/compiler.ts | 707 +++++++++ views/engine/lexer.ts | 616 ++++++++ views/engine/parser.ts | 485 ++++++ views/engine/runtime.ts | 562 +++++++ views/engine/types.ts | 244 +++ views/hooks.ts | 246 +++ views/index.ts | 18 + views/integrations.ts | 134 ++ views/language.ts | 24 + views/markdown.ts | 276 ++++ views/raw.ts | 96 ++ views/stage.ts | 69 + views/testing.ts | 263 ++++ 320 files changed, 27303 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 adapters/cloudflare-wrangler.ts create mode 100644 adapters/index.ts create mode 100644 adapters/prediction.ts create mode 100644 adapters/server-bun.ts create mode 100644 adapters/static.ts create mode 100644 adapters/vercel-vercel.ts create mode 100644 biome.json create mode 100644 bun.lock create mode 100755 cli.ts create mode 100644 contracts/index.ts create mode 100644 contracts/typegen/generator.ts create mode 100644 contracts/typegen/http/headers.ts create mode 100644 contracts/typegen/http/index.ts create mode 100644 contracts/typegen/http/method.ts create mode 100644 contracts/typegen/http/request.ts create mode 100644 contracts/typegen/http/respcode.ts create mode 100644 contracts/typegen/http/response.ts create mode 100644 contracts/typegen/minify.ts create mode 100644 contracts/typegen/orm/field.ts create mode 100644 contracts/typegen/orm/index.ts create mode 100644 contracts/typegen/orm/repository.ts create mode 100644 contracts/typegen/orm/table.ts create mode 100644 contracts/typegen/orm/tables.ts create mode 100644 contracts/typegen/views.ts create mode 100644 core/app.ts create mode 100644 core/backport.ts create mode 100644 core/cache.ts create mode 100644 core/config.ts create mode 100644 core/container.ts create mode 100644 core/content.ts create mode 100644 core/context.ts create mode 100644 core/errors.ts create mode 100644 core/form.ts create mode 100644 core/format-source.ts create mode 100644 core/format.ts create mode 100644 core/http.ts create mode 100644 core/index.ts create mode 100644 core/metadata.ts create mode 100644 core/modules.ts create mode 100644 core/paths.ts create mode 100644 core/pipeline.ts create mode 100644 core/router.ts create mode 100644 core/sessions.ts create mode 100644 core/templates.ts create mode 100644 ct.config.ts create mode 100644 decorators/Astro.ts create mode 100644 decorators/Auth.ts create mode 100644 decorators/Component.ts create mode 100644 decorators/Controller.ts create mode 100644 decorators/Inject.ts create mode 100644 decorators/Module.ts create mode 100644 decorators/Params.ts create mode 100644 decorators/RenderAhead.ts create mode 100644 decorators/RenderRoute.ts create mode 100644 decorators/Root.ts create mode 100644 decorators/Route.ts create mode 100644 decorators/Service.ts create mode 100644 decorators/View.ts create mode 100644 decorators/index.ts create mode 100644 editors/vscode/.vscodeignore create mode 100644 editors/vscode/bun.lock create mode 100644 editors/vscode/language-configuration.json create mode 100644 editors/vscode/muddle-language-tools-0.1.0.vsix create mode 100644 editors/vscode/package.json create mode 100644 editors/vscode/snippets/spug.json create mode 100644 editors/vscode/snippets/stcss.json create mode 100644 editors/vscode/src/extension.js create mode 100644 editors/vscode/syntaxes/spug.tmLanguage.json create mode 100644 editors/vscode/syntaxes/stcss.tmLanguage.json create mode 100644 eslint.config.js create mode 100644 index.ts create mode 100644 integrations/auth.ts create mode 100644 integrations/fonts.ts create mode 100644 integrations/hooks.ts create mode 100644 integrations/optimize.ts create mode 100644 integrations/react.ts create mode 100644 integrations/responsivity.ts create mode 100644 integrations/shadcn.ts create mode 100644 justfile create mode 100644 modules/amezing/README.md create mode 100644 modules/amezing/components/alert.spug create mode 100644 modules/amezing/components/article-list.spug create mode 100644 modules/amezing/components/article-nav.spug create mode 100644 modules/amezing/components/article.spug create mode 100644 modules/amezing/components/audio-button.spug create mode 100644 modules/amezing/components/audio.spug create mode 100644 modules/amezing/components/banner.spug create mode 100644 modules/amezing/components/base.spug create mode 100644 modules/amezing/components/comments.spug create mode 100644 modules/amezing/components/crt.spug create mode 100644 modules/amezing/components/deanchor.spug create mode 100644 modules/amezing/components/emoji.spug create mode 100644 modules/amezing/components/footnotes.spug create mode 100644 modules/amezing/components/fuse.spug create mode 100644 modules/amezing/components/icon.spug create mode 100644 modules/amezing/components/image.spug create mode 100644 modules/amezing/components/link.spug create mode 100644 modules/amezing/components/mastodon.spug create mode 100644 modules/amezing/components/overflow-auto.spug create mode 100644 modules/amezing/components/paginator.spug create mode 100644 modules/amezing/components/search-fuse.spug create mode 100644 modules/amezing/components/search-modal.spug create mode 100644 modules/amezing/components/skip-to-content.spug create mode 100644 modules/amezing/components/statement.spug create mode 100644 modules/amezing/components/tag-chips.spug create mode 100644 modules/amezing/components/video.spug create mode 100644 modules/amezing/components/vimeo.spug create mode 100644 modules/amezing/components/youtube.spug create mode 100644 modules/amezing/fonts/commissioner.woff2 create mode 100644 modules/amezing/fonts/commit-mono.woff2 create mode 100644 modules/amezing/index.ts create mode 100644 modules/amezing/package.json create mode 100644 modules/amezing/sass/amezing.scss create mode 100644 modules/amezing/sass/citrine/abstracts/_functions.scss create mode 100644 modules/amezing/sass/citrine/abstracts/_mixins.scss create mode 100644 modules/amezing/sass/citrine/abstracts/_variables.scss create mode 100644 modules/amezing/sass/citrine/base/_animations.scss create mode 100644 modules/amezing/sass/citrine/base/_base.scss create mode 100644 modules/amezing/sass/citrine/base/_layout.scss create mode 100644 modules/amezing/sass/citrine/base/_reset.scss create mode 100644 modules/amezing/sass/citrine/base/_root.scss create mode 100644 modules/amezing/sass/citrine/base/_typography.scss create mode 100644 modules/amezing/sass/citrine/components/_buttons.scss create mode 100644 modules/amezing/sass/citrine/components/_card.scss create mode 100644 modules/amezing/sass/citrine/components/_crt.scss create mode 100644 modules/amezing/sass/citrine/components/_external.scss create mode 100644 modules/amezing/sass/citrine/components/_iframe.scss create mode 100644 modules/amezing/sass/citrine/components/_input.scss create mode 100644 modules/amezing/sass/citrine/components/_media.scss create mode 100644 modules/amezing/sass/citrine/components/_overflow-auto.scss create mode 100644 modules/amezing/sass/citrine/components/_overshoot.scss create mode 100644 modules/amezing/sass/citrine/components/_skip-to-content.scss create mode 100644 modules/amezing/sass/citrine/components/_spoiler.scss create mode 100644 modules/amezing/sass/citrine/components/_table.scss create mode 100644 modules/amezing/sass/citrine/components/_textarea.scss create mode 100644 modules/amezing/sass/citrine/components/_title.scss create mode 100644 modules/amezing/sass/citrine/components/_tooltip.scss create mode 100644 modules/amezing/sass/citrine/components/_visually-hidden.scss create mode 100644 modules/amezing/sass/citrine/style.scss create mode 100644 modules/amezing/sass/components/_alerts.scss create mode 100644 modules/amezing/sass/components/_article-details.scss create mode 100644 modules/amezing/sass/components/_article-list.scss create mode 100644 modules/amezing/sass/components/_article-nav.scss create mode 100644 modules/amezing/sass/components/_audio-button.scss create mode 100644 modules/amezing/sass/components/_audio.scss create mode 100644 modules/amezing/sass/components/_banner.scss create mode 100644 modules/amezing/sass/components/_comments.scss create mode 100644 modules/amezing/sass/components/_deanchor.scss create mode 100644 modules/amezing/sass/components/_emoji.scss create mode 100644 modules/amezing/sass/components/_footnotes.scss create mode 100644 modules/amezing/sass/components/_fuse.scss create mode 100644 modules/amezing/sass/components/_giallo.scss create mode 100644 modules/amezing/sass/components/_icon.scss create mode 100644 modules/amezing/sass/components/_iframe.scss create mode 100644 modules/amezing/sass/components/_index.scss create mode 100644 modules/amezing/sass/components/_marquee.scss create mode 100644 modules/amezing/sass/components/_not-found.scss create mode 100644 modules/amezing/sass/components/_pre-container.scss create mode 100644 modules/amezing/sass/components/_search-fuse.scss create mode 100644 modules/amezing/sass/components/_search-modal.scss create mode 100644 modules/amezing/sass/components/_skip-to-content.scss create mode 100644 modules/amezing/sass/components/_statements.scss create mode 100644 modules/amezing/sass/components/_tag-chips.scss create mode 100644 modules/amezing/sass/components/_zola-anchor.scss create mode 100644 modules/amezing/sass/components/_zola-error.scss create mode 100644 modules/amezing/sass/fonts.scss create mode 100644 modules/amezing/sass/layout/_footer.scss create mode 100644 modules/amezing/sass/layout/_full-bleed.scss create mode 100644 modules/amezing/sass/layout/_grid.scss create mode 100644 modules/amezing/sass/layout/_header.scss create mode 100644 modules/amezing/sass/layout/_index.scss create mode 100644 modules/amezing/sass/layout/_main-content.scss create mode 100644 modules/amezing/sass/layout/_sidebar.scss create mode 100644 modules/amezing/sass/mods/_compact-sidebar.scss create mode 100644 modules/amezing/sass/mods/_flat.scss create mode 100644 modules/amezing/sass/mods/_pill-tags.scss create mode 100644 modules/amezing/sass/mods/_skeuomorphic.scss create mode 100644 modules/amezing/sass/style.scss create mode 100644 modules/amezing/scripts/audio-button.js create mode 100644 modules/amezing/scripts/closable.js create mode 100644 modules/amezing/scripts/deanchor.js create mode 100644 modules/amezing/scripts/fuse.js create mode 100644 modules/amezing/scripts/marquee.js create mode 100644 modules/amezing/scripts/paginator.js create mode 100644 modules/amezing/scripts/search-fuse.js create mode 100644 modules/amezing/styles/ametrine.css create mode 100644 modules/amezing/styles/vars.css create mode 100644 modules/analysis/README.md create mode 100644 modules/analysis/index.ts create mode 100644 modules/analysis/inspect.ts create mode 100644 modules/analysis/package.json create mode 100644 modules/astro/index.ts create mode 100644 modules/astro/package.json create mode 100644 modules/database/index.ts create mode 100644 modules/database/package.json create mode 100644 modules/database/src/adapters/d1.ts create mode 100644 modules/database/src/adapters/driver.ts create mode 100644 modules/database/src/adapters/pg.ts create mode 100644 modules/database/src/adapters/sqlite.ts create mode 100644 modules/database/src/database.ts create mode 100644 modules/database/src/decorators/Field.ts create mode 100644 modules/database/src/decorators/Hydrate.ts create mode 100644 modules/database/src/decorators/Table.ts create mode 100644 modules/database/src/hydration.ts create mode 100644 modules/database/src/index.ts create mode 100644 modules/database/src/migrations.ts create mode 100644 modules/database/src/repository.ts create mode 100644 modules/database/src/sessions.ts create mode 100644 modules/latex/index.ts create mode 100644 modules/latex/package.json create mode 100644 modules/mdx/index.ts create mode 100644 modules/mdx/package.json create mode 100644 modules/optimize/README.md create mode 100644 modules/optimize/index.ts create mode 100644 modules/optimize/package.json create mode 100644 modules/prettier-plugin/index.ts create mode 100644 modules/prettier-plugin/package.json create mode 100644 modules/shadcn/index.ts create mode 100644 modules/shadcn/package.json create mode 100644 modules/tailwind/index.ts create mode 100644 modules/tailwind/package.json create mode 100644 package.json create mode 100644 samples/hello-world/.gitignore create mode 100644 samples/hello-world/_muddle/contracts/index.ts create mode 100644 samples/hello-world/_muddle/contracts/minify.ts create mode 100644 samples/hello-world/_muddle/contracts/orm/tables.ts create mode 100644 samples/hello-world/_muddle/contracts/views.ts create mode 100644 samples/hello-world/config.ts create mode 100644 samples/hello-world/ct.config.ts create mode 100644 samples/hello-world/index.ts create mode 100644 samples/hello-world/modules/app/app.controller.ts create mode 100644 samples/hello-world/modules/app/app.module.ts create mode 100644 samples/hello-world/modules/app/asset.controller.ts create mode 100644 samples/hello-world/modules/app/public/robots.txt create mode 100644 samples/hello-world/modules/app/views/index.spug create mode 100644 samples/hello-world/modules/app/views/layout.spug create mode 100644 samples/hello-world/modules/app/views/post.spug create mode 100644 samples/hello-world/repositories/posts.repository.ts create mode 100644 samples/hello-world/styles/app.stcss create mode 100644 samples/hello-world/tsconfig.json create mode 100644 samples/react/.gitignore create mode 100644 samples/react/README.md create mode 100644 samples/react/_muddle/contracts/index.ts create mode 100644 samples/react/_muddle/contracts/minify.ts create mode 100644 samples/react/_muddle/contracts/orm/tables.ts create mode 100644 samples/react/_muddle/contracts/views.ts create mode 100644 samples/react/config.ts create mode 100644 samples/react/ct.config.ts create mode 100644 samples/react/index.ts create mode 100644 samples/react/modules/app/albumStyle.ts create mode 100644 samples/react/modules/app/app.controller.ts create mode 100644 samples/react/modules/app/app.module.ts create mode 100644 samples/react/modules/app/musicbrainz.ts create mode 100644 samples/react/modules/app/registry.ts create mode 100644 samples/react/modules/app/storage/interpolate.txt create mode 100644 samples/react/modules/app/storage/predict.txt create mode 100644 samples/react/modules/app/storage/refer.txt create mode 100644 samples/react/modules/app/storage/route.txt create mode 100644 samples/react/modules/app/views/feature.spug create mode 100644 samples/react/modules/app/views/index.spug create mode 100644 samples/react/modules/app/views/layout.spug create mode 100644 samples/react/modules/app/views/navbar.spug create mode 100644 samples/react/styles/app.stcss create mode 100644 samples/react/tsconfig.json create mode 100644 samples/shadcn-porter/config.ts create mode 100644 samples/shadcn-porter/ct.config.ts create mode 100644 samples/shadcn-porter/dist.previous/_muddle/styles.css create mode 100644 samples/shadcn-porter/dist.previous/index.html create mode 100644 samples/shadcn-porter/index.ts create mode 100644 samples/shadcn-porter/modules/app/app.module.ts create mode 100644 samples/shadcn-porter/modules/app/landing.controller.ts create mode 100644 samples/shadcn-porter/modules/app/views/components/button.spug create mode 100644 samples/shadcn-porter/modules/app/views/components/card.spug create mode 100644 samples/shadcn-porter/modules/app/views/index.spug create mode 100644 samples/shadcn-porter/styles/landing.stcss create mode 100644 samples/shadcn-porter/tsconfig.json create mode 100644 tests/amezing.test.ts create mode 100644 tests/analysis.test.ts create mode 100644 tests/backport.test.ts create mode 100644 tests/components.test.ts create mode 100644 tests/core.test.ts create mode 100644 tests/css.test.ts create mode 100644 tests/d1.test.ts create mode 100644 tests/hello-world.test.ts create mode 100644 tests/hooks.test.ts create mode 100644 tests/integrations.test.ts create mode 100644 tests/markdown.test.ts create mode 100644 tests/modules.test.ts create mode 100644 tests/orm.test.ts create mode 100644 tests/postgres.test.ts create mode 100644 tests/prediction.test.ts create mode 100644 tests/router.test.ts create mode 100644 tests/sessions-format.test.ts create mode 100644 tests/stage.test.ts create mode 100644 tests/templates-generator.test.ts create mode 100644 tests/templates.test.ts create mode 100644 tests/views.test.ts create mode 100644 tsconfig.json create mode 100644 views/api.ts create mode 100644 views/components.ts create mode 100644 views/css/compiler.ts create mode 100644 views/css/evaluate.ts create mode 100644 views/css/index.ts create mode 100644 views/css/parser.ts create mode 100644 views/css/sass.ts create mode 100644 views/engine/compiler.ts create mode 100644 views/engine/lexer.ts create mode 100644 views/engine/parser.ts create mode 100644 views/engine/runtime.ts create mode 100644 views/engine/types.ts create mode 100644 views/hooks.ts create mode 100644 views/index.ts create mode 100644 views/integrations.ts create mode 100644 views/language.ts create mode 100644 views/markdown.ts create mode 100644 views/raw.ts create mode 100644 views/stage.ts create mode 100644 views/testing.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e609278 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# port upstream +ametrine/ + +# shit small-time slopcoding +.opencode/ + +/_muddle \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..720ed16 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# muddle + +> [!CAUTION] +> MUDDLE IS EXPERIMENTAL SOFTWARE. EXPECT LITERALLY ANYTHING TO BREAK AT ANY TIME. +> DURING TESTING THINGS HAVE WORKED. THEY WILL NOT ALWAYS. + +Muddle is a Muddle Dark Wet Dub SSG. \ No newline at end of file diff --git a/adapters/cloudflare-wrangler.ts b/adapters/cloudflare-wrangler.ts new file mode 100644 index 0000000..ea46b7c --- /dev/null +++ b/adapters/cloudflare-wrangler.ts @@ -0,0 +1,53 @@ +import type { App } from "../core/app.ts"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export interface WorkerEnv { + [key: string]: unknown; +} + +export interface WorkerContext { + waitUntil(promise: Promise): void; + passThroughOnException?(): void; +} + +export interface WorkerOptions { + /** Builds the application from the bindings of the worker. */ + create?: (env: WorkerEnv) => App | Promise; +} + +export interface WranglerSetupOptions { + name?: string; + main?: string; + compatibilityDate?: string; +} + +/** Creates a minimal Wrangler config when a project has not supplied one. */ +export async function setupWrangler(root: string, options: WranglerSetupOptions = {}): Promise { + const path = join(resolve(root), "wrangler.jsonc"); + if (existsSync(path)) return path; + const source = JSON.stringify({ name: options.name ?? "muddle-app", main: options.main ?? "src/worker.ts", compatibility_date: options.compatibilityDate ?? "2026-10-22" }, null, 2).concat("\n"); + await writeFile(path, source, "utf8"); + return path; +} + +/** Creates the fetch handler of a worker. */ +export function createWorker(app: App | WorkerOptions["create"]): { + fetch( + request: Request, + env: WorkerEnv, + ctx: WorkerContext, + ): Promise; +} { + let instance: App | undefined = typeof app === "function" ? undefined : app; + + return { + async fetch(request: Request, env: WorkerEnv, _ctx: WorkerContext) { + if (!instance) { + instance = await (app as (env: WorkerEnv) => Promise)(env); + } + return instance.fetch(request); + }, + }; +} diff --git a/adapters/index.ts b/adapters/index.ts new file mode 100644 index 0000000..92ea9f7 --- /dev/null +++ b/adapters/index.ts @@ -0,0 +1,4 @@ +export * from "./cloudflare-wrangler.ts"; +export * from "./server-bun.ts"; +export * from "./static.ts"; +export * from "./vercel-vercel.ts"; diff --git a/adapters/prediction.ts b/adapters/prediction.ts new file mode 100644 index 0000000..637495d --- /dev/null +++ b/adapters/prediction.ts @@ -0,0 +1,442 @@ +export type BreakpointAcc = "XL" | "LG" | "MD" | "SM"; + +export interface LayoutPredictionOptions { + selectors?: string[]; + viewport?: { width: number; height: number }; + strict?: boolean; + mobileViewport?: { width: number; height: number }; + /** Media query receiving the mobile relation values. Defaults to the page's widest max-width query. */ + mobileMediaQuery?: string; + /** Named responsive layers to calculate ahead of time. Multiple values produce cascading relation layers. */ + breakpointAcc?: BreakpointAcc | BreakpointAcc[]; + /** Maximum number of relation propagation passes before a static build fails. */ + maxPasses?: number; + /** Emit discovered dotted-anchor group metadata for reflective selectors. Enabled by default. */ + reflective?: boolean; +} + +export interface LayoutPrediction { + selector: string; + index: number; + width: number; + height: number; + x: number; + y: number; + mobile?: boolean; + anchor?: string | null; + backgroundColor?: string; + color?: string; + transition?: string; + /** Extra computed properties requested through `$refer[](...)`. */ + relate?: Record; + /** Scoped mirrors of the element itself, requested through `$self[]`. */ + self?: Record; +} + +export interface ReflectiveAnchorGroup { + name: string; + anchors: string[]; +} + +/** + * Measures a rendered page inside headless chromium. + * + * The script discovers which `$refer` and `$self` properties the shipped + * stylesheets request, labels every measure target and mirrors the requested + * properties for every anchor element. Geometry properties (`scroll-height`, + * `client-width`, ...) are read as numbers off the element, every other name + * is read from its computed style. + * + * The pass runs twice: once for the desktop viewport and, with `remeasure`, + * again on the mobile viewport where only the previously labelled targets are + * visited. + */ +const measureScript = (input: { remeasure: boolean; selectors?: string[] }) => { + const { remeasure, selectors = [] } = input; + const document = (globalThis as unknown as { document: any }).document; + const getComputedStyle = (globalThis as unknown as { getComputedStyle: (element: any) => any }).getComputedStyle; + type Entry = { + selector: string; + index: number; + width: number; + height: number; + x: number; + y: number; + anchor: string | null; + backgroundColor: string; + color: string; + transition: string; + relate?: Record; + self?: Record; + }; + + const GEOMETRY = new Set([ + "scroll-height", + "scroll-width", + "client-height", + "client-width", + "offset-height", + "offset-width", + ]); + + const STANDARDS = new Set([ + "x", + "y", + "width", + "height", + "background-color", + "color", + "transition", + ]); + + const propertyValue = (element: any, style: any, property: string): string => { + switch (property) { + case "scroll-height": return `${element.scrollHeight}px`; + case "scroll-width": return `${element.scrollWidth}px`; + case "client-height": return `${element.clientHeight}px`; + case "client-width": return `${element.clientWidth}px`; + case "offset-height": return `${element.offsetHeight}px`; + case "offset-width": return `${element.offsetWidth}px`; + default: return style.getPropertyValue(property) ?? ""; + } + }; + + // Which `$refer` and `$self` properties do the shipped stylesheets request? + const styles: string[] = []; + for (const sheet of [...document.styleSheets]) { + try { + for (const rule of [...sheet.cssRules]) styles.push(rule.cssText); + } catch { + // cross-origin stylesheets are unreadable, keep going + } + } + const selfProps: string[] = []; + const seenSelf = new Set(); + for (const text of styles) { + for (const match of text.matchAll(/--muddle-self-([a-zA-Z][\w-]*)/g)) { + if (!seenSelf.has(match[1]!)) { + seenSelf.add(match[1]!); + selfProps.push(match[1]!); + } + } + } + const anchors = [...document.querySelectorAll("[data-muddle-anchor]")] as any[]; + if (!remeasure) { + for (const element of anchors) { + const name = element.getAttribute("data-muddle-anchor") as string | null; + if (!name?.includes(".")) continue; + const parts = name.split("."); + const groups = parts.slice(0, -1).map((_part, index) => parts.slice(0, index + 1).join(".")); + element.setAttribute("data-muddle-group", groups.join(" ")); + } + } + const requests = new Map(); + for (const element of anchors) { + const name = element.getAttribute("data-muddle-anchor"); + if (!name) continue; + const key = name.replace(/[^a-zA-Z0-9_-]/g, "_"); + for (const text of styles) { + const needle = `--muddle-relate-${key}-`; + let index = text.indexOf(needle); + while (index !== -1) { + const property = /^[a-zA-Z][\w-]*/.exec(text.slice(index + needle.length))?.[0]; + if (property && !requests.get(key)?.includes(property)) { + requests.set(key, [...(requests.get(key) ?? []), property]); + } + index = text.indexOf(needle, index + needle.length + (property?.length ?? 0)); + } + } + } + + // Label the measure targets once, they keep their id across viewports. + if (!remeasure) { + const selected = new Set(); + for (const selector of selectors) { + for (const element of document.querySelectorAll(selector)) selected.add(element); + } + for (const [index, element] of [...selected].entries()) { + element.setAttribute("data-muddle-predicted", `predict-${index}`); + } + } + + const entries: Entry[] = []; + for (const [index, element] of ([...document.querySelectorAll("[data-muddle-predicted]")] as any[]).entries()) { + const box = element.getBoundingClientRect(); + const style = getComputedStyle(element); + const entry: Entry = { + selector: `[data-muddle-predicted="${element.getAttribute("data-muddle-predicted")}"]`, + index, + width: box.width, + height: box.height, + x: box.x, + y: box.y, + anchor: element.getAttribute("data-muddle-anchor"), + backgroundColor: style.backgroundColor, + color: style.color, + transition: style.transition, + }; + const name = element.getAttribute("data-muddle-anchor"); + if (name) { + const key = name.replace(/[^a-zA-Z0-9_-]/g, "_"); + const relate: Record = {}; + for (const property of requests.get(key) ?? []) { + if (STANDARDS.has(property)) continue; + const value = propertyValue(element, style, property); + if (value) relate[property] = value; + } + if (Object.keys(relate).length > 0) entry.relate = relate; + const self: Record = {}; + for (const property of selfProps) { + const value = propertyValue(element, style, property); + if (value) self[property] = value; + } + if (Object.keys(self).length > 0) entry.self = self; + } + entries.push(entry); + } + return entries; +}; + +function predictionVariables(prediction: LayoutPrediction, variant: string): string { + return [ + `${prediction.selector} {`, + `--muddle-predicted-${variant}-width: ${prediction.width}px;`, + `--muddle-predicted-${variant}-height: ${prediction.height}px;`, + `--muddle-predicted-${variant}-x: ${prediction.x}px;`, + `--muddle-predicted-${variant}-y: ${prediction.y}px;`, + `--muddle-predicted-width: var(--muddle-predicted-${variant}-width);`, + `--muddle-predicted-height: var(--muddle-predicted-${variant}-height);`, + `--muddle-predicted-x: var(--muddle-predicted-${variant}-x);`, + `--muddle-predicted-y: var(--muddle-predicted-${variant}-y);`, + "}", + ].join(" "); +} + +/** + * Relation variables of one anchor. + * + * The suffixed variables hold the raw measurement of the variant, the plain + * alias resolves to whichever variant applies at the current viewport. + */ +function relationVariables(prediction: LayoutPrediction, variant: string): string { + if (!prediction.anchor) return ""; + const key = prediction.anchor.replace(/[^a-zA-Z0-9_-]/g, "_"); + const prefix = `--muddle-relate-${key}-${variant}`; + const values = [ + `${prefix}-x: ${prediction.x}px`, + `${prefix}-y: ${prediction.y}px`, + `${prefix}-width: ${prediction.width}px`, + `${prefix}-height: ${prediction.height}px`, + prediction.backgroundColor ? `${prefix}-background-color: ${prediction.backgroundColor}` : "", + prediction.color ? `${prefix}-color: ${prediction.color}` : "", + prediction.transition ? `${prefix}-transition: ${prediction.transition}` : "", + `--muddle-relate-${key}-x: var(${prefix}-x)`, + `--muddle-relate-${key}-y: var(${prefix}-y)`, + `--muddle-relate-${key}-width: var(${prefix}-width)`, + `--muddle-relate-${key}-height: var(${prefix}-height)`, + prediction.backgroundColor ? `--muddle-relate-${key}-background-color: var(${prefix}-background-color)` : "", + prediction.color ? `--muddle-relate-${key}-color: var(${prefix}-color)` : "", + prediction.transition ? `--muddle-relate-${key}-transition: var(${prefix}-transition)` : "", + ] + .concat(Object.entries(prediction.relate ?? {}).flatMap(([property, value]) => [ + `${prefix}-${property}: ${value}`, + `--muddle-relate-${key}-${property}: var(${prefix}-${property})`, + ])) + .filter(Boolean); + return `:root {\n\t${values.join(";\n")};\n}`; +} + +/** + * Scoped `$self` mirrors of one anchor element. + * + * `$self[]` resolves through the element itself, so the mirrored + * value cannot leak between elements that show the same anchor name once. + */ +function selfVariables(prediction: LayoutPrediction): string { + if (!prediction.anchor) return ""; + const entries = Object.entries(prediction.self ?? {}); + if (entries.length === 0) return ""; + const values = entries.map(([property, value]) => `--muddle-self-${property}: ${value}`); + return `[data-muddle-anchor="${prediction.anchor}"] {\n\t${values.join(";\n")};\n}`; +} + +interface PredictionLayer { + name: string; + media: string; + viewport: { width: number; height: number }; + predictions: LayoutPrediction[]; +} + +function reflectiveAnchorGroups(predictions: LayoutPrediction[]): ReflectiveAnchorGroup[] { + const groups = new Map(); + for (const prediction of predictions) { + const anchor = prediction.anchor; + if (!anchor?.includes(".")) continue; + const parts = anchor.split("."); + for (let index = 1; index < parts.length; index += 1) { + const name = parts.slice(0, index).join("."); + const entries = groups.get(name) ?? []; + if (!entries.includes(anchor)) entries.push(anchor); + groups.set(name, entries); + } + } + return [...groups].map(([name, anchors]) => ({ name, anchors })); +} + +function reflectionCss(groups: ReflectiveAnchorGroup[]): string[] { + return groups.flatMap((group) => { + const key = group.name.replace(/[^a-zA-Z0-9_-]/g, "_"); + return [ + `[data-muddle-group~="${group.name}"] { --muddle-reflect-${key}-count: ${group.anchors.length}; }`, + ...group.anchors.map((anchor, index) => `[data-muddle-anchor="${anchor}"] { --muddle-reflect-${key}-index: ${index}; }`), + ]; + }); +} + +function predictionCss(desktop: LayoutPrediction[], layers: PredictionLayer[], groups: ReflectiveAnchorGroup[]): string { + const rules = (predictions: LayoutPrediction[], variant: string) => predictions.flatMap((entry) => [ + predictionVariables(entry, variant), + relationVariables(entry, variant), + selfVariables(entry), + ]); + return [ + ...reflectionCss(groups), + "@media (min-width: 0) {", + ...rules(desktop, "desktop"), + "}", + ...layers.flatMap((layer) => [`@media ${layer.media} {`, ...rules(layer.predictions, layer.name), "}"]), + ] + .filter((line) => line.trim().length > 0) + .join("\n"); +} + +function mobileMediaQuery(html: string, explicit?: string): string { + if (explicit) return explicit.trim().startsWith("(") ? explicit.trim() : `(${explicit.trim()})`; + const candidates = [...html.matchAll(/@media\s*\(\s*max-width\s*:\s*([^)]+)\)/gi)] + .map((match) => match[1]!.trim()) + .sort((left, right) => mediaWidth(right) - mediaWidth(left)); + return candidates[0] ? `(max-width: ${candidates[0]})` : "(max-width: 48rem)"; +} + +function mediaWidth(value: string): number { + const match = /^(\d+(?:\.\d+)?)\s*(px|rem|em)?$/i.exec(value); + if (!match) return 0; + const amount = Number(match[1]); + return amount * (match[2]?.toLowerCase() === "px" ? 1 : 16); +} + +function viewportForMobileMedia(query: string, fallback: { width: number; height: number }): { width: number; height: number } { + const match = /max-width\s*:\s*([^)\s]+)/i.exec(query); + const width = match ? mediaWidth(match[1]!) : 0; + return width > 0 ? { ...fallback, width: Math.min(fallback.width, Math.max(1, Math.floor(width - 1))) } : fallback; +} + +const BREAKPOINTS: Record = { + XL: { width: 1280, media: "(max-width: 80rem)" }, + LG: { width: 1024, media: "(max-width: 64rem)" }, + MD: { width: 768, media: "(max-width: 48rem)" }, + SM: { width: 640, media: "(max-width: 40rem)" }, +}; + +function predictionLayers(options: LayoutPredictionOptions, html: string): Omit[] { + const requested = options.breakpointAcc + ? [...new Set(Array.isArray(options.breakpointAcc) ? options.breakpointAcc : [options.breakpointAcc])] + : []; + if (requested.length) { + return requested + .sort((left, right) => BREAKPOINTS[right].width - BREAKPOINTS[left].width) + .map((accuracy) => ({ + name: accuracy.toLowerCase(), + media: BREAKPOINTS[accuracy].media, + viewport: { width: BREAKPOINTS[accuracy].width - 1, height: 844 }, + })); + } + const media = mobileMediaQuery(html, options.mobileMediaQuery); + return [{ name: "mobile", media, viewport: options.mobileViewport ?? viewportForMobileMedia(media, { width: 390, height: 844 }) }]; +} + +/** Reads the `@measure` targets emitted into stylesheets by the STCSS compiler. */ +export function extractPredictSelectors(html: string): string[] { + return [...html.matchAll(/\/\*\s*muddle-predict\s*:([\s\S]*?)\*\//g)].flatMap((match) => (match[1] + ? match[1].split(/[|,]/).map((selector) => selector.trim()).filter(Boolean) + : [])); +} + +/** + * Bakes prediction and relation variables into prerendered html. + * + * A headless chromium renders the page at the desktop viewport, measures it, + * then repeats at the mobile viewport. The results are inlined as a single + * ``), predictions: [...desktop, ...measuredLayers.flatMap((layer) => layer.predictions)], groups }; + } catch (error) { + if (options.strict) throw error; + console.warn(`muddle prediction skipped: ${(error as Error).message.split("\n")[0]}`); + return { html, predictions: [], groups: [] }; + } finally { + await browser?.close(); + } +} diff --git a/adapters/server-bun.ts b/adapters/server-bun.ts new file mode 100644 index 0000000..3cb8994 --- /dev/null +++ b/adapters/server-bun.ts @@ -0,0 +1,123 @@ +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { extname, isAbsolute, join, relative, resolve } from "node:path"; +import type { App } from "../core/app.ts"; +import { toFetchResponse } from "../core/app.ts"; +import { contentTypeOf } from "../core/http.ts"; +import { predictLayout } from "./prediction.ts"; +import { inlinePredictionStyles } from "./static.ts"; + +export interface BunServerOptions { + /** Port the server listens on, `0` picks a free port. */ + port?: number; + /** Hostname the server binds to. */ + hostname?: string; + /** Directory with the static assets. */ + publicDir?: string; + /** Called for every request, useful for logging. */ + onRequest?: (request: Request) => void; + /** Predict and cache rendered HTML before its first response. Defaults to true. */ + predictViews?: boolean; +} + +interface CachedResponse { + body: ArrayBuffer; + status: number; + statusText: string; + headers: [string, string][]; +} + +/** Serves the application. */ +export function serve(app: App, options: BunServerOptions = {}) { + const publicDir = options.publicDir ? resolve(options.publicDir) : undefined; + const rendered = new Map>(); + + return Bun.serve({ + port: options.port ?? 3000, + hostname: options.hostname ?? "0.0.0.0", + async fetch(request) { + options.onRequest?.(request); + if (publicDir) { + const asset = await serveAsset(request, publicDir); + if (asset) return asset; + } + if (options.predictViews === false || request.method !== "GET") return app.fetch(request); + const key = request.url; + let predicted = rendered.get(key); + if (!predicted) { + predicted = renderPredictedView(app, request).then(cacheResponse); + rendered.set(key, predicted); + } + try { + return await cloneResponse(await predicted); + } catch (error) { + rendered.delete(key); + throw error; + } + }, + }); +} + +async function renderPredictedView(app: App, request: Request): Promise { + const response = await app.fetch(request); + const contentType = response.headers.get("content-type") ?? ""; + if (!response.ok || !contentType.includes("text/html") || response.headers.has("set-cookie") || /no-store/i.test(response.headers.get("cache-control") ?? "")) return response; + const html = await response.text(); + const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); + if (!hasRelations) return responseFromText(html, response); + const assetHtml = await inlinePredictionStyles(app, html, new URL(request.url).origin); + const predicted = await predictLayout(assetHtml, { strict: true }); + return responseFromText(predicted.html, response); +} + +function responseFromText(html: string, source: Response): Response { + const headers = new Headers(source.headers); + headers.delete("content-length"); + return new Response(html, { status: source.status, statusText: source.statusText, headers }); +} + +function cloneResponse(source: CachedResponse): Response { + return new Response(source.body.slice(0), { + status: source.status, + statusText: source.statusText, + headers: source.headers, + }); +} + +async function cacheResponse(source: Response): Promise { + return { + body: await source.arrayBuffer(), + status: source.status, + statusText: source.statusText, + headers: [...source.headers], + }; +} + +/** Serves a static asset of the public directory. */ +export async function serveAsset( + request: Request, + publicDir: string, +): Promise { + const url = new URL(request.url); + if (request.method !== "GET" && request.method !== "HEAD") return undefined; + + const requested = resolve(publicDir, decodeURIComponent(url.pathname).replace(/^[/\\]+/, "")); + const relativePath = relative(publicDir, requested); + if (relativePath.startsWith("..") || isAbsolute(relativePath)) return undefined; + const path = existsSync(join(requested, "index.html")) ? join(requested, "index.html") : requested; + if (!existsSync(path)) return undefined; + + let file: Uint8Array; + try { + file = await readFile(path); + } catch { + return undefined; + } + return new Response(file, { + headers: { + "content-type": contentTypeOf(extname(path)), + }, + }); +} + +export { toFetchResponse }; diff --git a/adapters/static.ts b/adapters/static.ts new file mode 100644 index 0000000..54c2d98 --- /dev/null +++ b/adapters/static.ts @@ -0,0 +1,151 @@ +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import type { App } from "../core/app.ts"; +import { getRenderAhead } from "../decorators/RenderAhead.ts"; +import { getRenderRoutes, type RenderRouteSource } from "../decorators/RenderRoute.ts"; +import { ContentRegistry } from "../core/content.ts"; +import { predictLayout, type LayoutPredictionOptions } from "./prediction.ts"; + +export interface PrerenderOptions { + /** Routes that are rendered at build time. */ + routes?: string[]; + /** Directory the html files are written to. */ + outDir: string; + /** Hostname used while rendering, defaults to `localhost`. */ + hostname?: string; + /** Protocol used while rendering. */ + protocol?: string; + /** Adds discovered dynamic routes before rendering. */ + discover?: (pattern: string) => Promise | string[]; + progress?: (event: { current: number; total: number; route: string }) => void; + predict?: LayoutPredictionOptions; +} + +export interface PrerenderResult { + /** Rendered route. */ + route: string; + /** File the html was written to. */ + file: string; + /** Status code of the response. */ + status: number; +} + +/** Renders the routes of the application into static html files. */ +export async function prerender( + app: App, + options: PrerenderOptions, +): Promise { + const outDir = resolve(options.outDir); + const stageDir = `${outDir}.staging`; + await rm(stageDir, { recursive: true, force: true }); + await mkdir(stageDir, { recursive: true }); + const origin = `${options.protocol ?? "http"}://${options.hostname ?? "localhost"}`; + const results: PrerenderResult[] = []; + + const routes = new Set(options.routes ?? []); + const content = app.resolvedConfig ? new ContentRegistry(app.resolvedConfig) : undefined; + if (content) await content.load(); + for (const route of app.router.list()) { + if (route.method !== "GET") continue; + if (!route.path.includes(":")) routes.add(route.path); + else { + for (const source of getRenderRoutes(route.controller, route.methodName)) { + for (const path of resolveRenderRoute(source, content)) routes.add(path); + } + const ahead = getRenderAhead(route.controller, route.methodName); + const values = ahead ? (typeof ahead === "function" ? await ahead() : ahead) : await options.discover?.(route.path); + for (const value of values ?? []) routes.add(value.startsWith("/") ? value : route.path.replace(/:[^/]+\??/, value)); + } + } + const orderedRoutes = [...routes].sort(); + for (const [index, route] of orderedRoutes.entries()) { + if (app.router.match("GET", route).kind === "not-found") { + throw new Error(`Cannot prerender unknown route "${route}"`); + } + options.progress?.({ current: index + 1, total: orderedRoutes.length, route }); + const request = new Request(new URL(route, origin).toString()); + const response = await app.fetch(request); + const contentType = response.headers.get("content-type") ?? ""; + + if (response.status >= 300 && response.status < 400) { + const location = response.headers.get("location"); + if (location) { + console.log(`muddle: ${route} redirects to ${location}, skipped`); + } + continue; + } + + if (!contentType.includes("text/html")) { + continue; + } + + let html = await response.text(); + // Relation anchors and STCSS prediction markers are static inputs to the + // layout pass. Run Playwright automatically so consumers never need to + // ship a geometry bridge merely because a page uses `$refer`. + const hasRelations = /data-muddle-anchor|muddle-predict\s*:|--muddle-relate-/.test(html); + if (options.predict || hasRelations) { + const assetHtml = await inlinePredictionStyles(app, html, origin); + html = (await predictLayout(assetHtml, options.predict ?? {})).html; + } + const file = fileFor(stageDir, route); + await mkdir(dirname(file), { recursive: true }); + const asset = await app.transformAsset({ path: file, contentType, content: html }); + const reports = await app.analyzePage({ route, html: await inlinePredictionStyles(app, html, origin) }); + if (Object.keys(reports).length) await writeFile(`${file}.analysis.json`, JSON.stringify(reports, null, 2)); + await writeFile(file, asset.content); + results.push({ route, file, status: response.status }); + } + + for (const route of app.router.listRaw()) { + if (route.method !== "GET") continue; + if (route.path.includes("*")) continue; + const request = new Request(new URL(route.path, origin).toString()); + const response = await app.fetch(request); + const file = join(stageDir, route.path.replace(/^\//, "")); + await mkdir(dirname(file), { recursive: true }); + const contentType = response.headers.get("content-type") ?? "application/octet-stream"; + const asset = await app.transformAsset({ path: file, contentType, content: new Uint8Array(await response.arrayBuffer()) }); + await writeFile(file, asset.content); + results.push({ route: route.path, file, status: response.status }); + } + + const backup = `${outDir}.previous`; + await rm(backup, { recursive: true, force: true }); + try { + await rename(outDir, backup); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + await rename(stageDir, outDir); + return results; +} + +/** Inlines same-origin styles so Playwright can measure a complete document. */ +export async function inlinePredictionStyles(app: App, html: string, origin: string): Promise { + for (const match of [...html.matchAll(/]*href="([^"]+)"[^>]*>/g)]) { + const url = new URL(match[1]!, origin); + if (url.origin !== origin || !match[0].includes('rel="stylesheet"')) continue; + const response = await app.fetch(new Request(url.href)); + if (response.ok) html = html.replace(match[0], ``); + } + return html; +} + +function resolveRenderRoute(source: RenderRouteSource, content?: ContentRegistry): string[] { + if (typeof source === "string") return [source]; + if (Array.isArray(source)) return source; + return (content?.entries(source.collection) ?? []).map((entry) => source.path.replace(/:id\??/g, encodeURIComponent(entry.id))); +} + +/** Path of the html file of a route. */ +export function fileFor(outDir: string, route: string): string { + const clean = route.split("?")[0] ?? "/"; + if (clean === "/" || clean === "") return join(outDir, "index.html"); + const segments = clean.split("/").filter((segment) => segment.length > 0); + const last = segments.pop(); + if (segments.length === 0) { + return join(outDir, `${last as string}.html`); + } + return join(outDir, ...segments, last as string, "index.html"); +} diff --git a/adapters/vercel-vercel.ts b/adapters/vercel-vercel.ts new file mode 100644 index 0000000..6f18243 --- /dev/null +++ b/adapters/vercel-vercel.ts @@ -0,0 +1,68 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { App } from "../core/app.ts"; +import { existsSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export type NodeHandler = ( + request: IncomingMessage, + response: ServerResponse, +) => Promise; + +/** Creates a minimal Vercel configuration when the project has none. */ +export async function setupVercel(root: string): Promise { + const path = join(resolve(root), "vercel.json"); + if (existsSync(path)) return path; + await writeFile(path, `${JSON.stringify({ cleanUrls: true }, null, 2)}\n`, "utf8"); + return path; +} + +/** Creates the node handler of the application. */ +export function createHandler(app: App): NodeHandler { + return async (request, response) => { + const url = `http://${request.headers.host ?? "localhost"}${request.url ?? "/"}`; + const method = (request.method ?? "GET").toUpperCase(); + + const headers = new Headers(); + for (const [name, value] of Object.entries(request.headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const entry of value) headers.append(name, entry); + continue; + } + headers.set(name, value); + } + + const body = + method === "GET" || method === "HEAD" + ? undefined + : await readBody(request); + + const fetchRequest = new Request(url, { + method, + headers, + body, + duplex: body ? "half" : undefined, + } as RequestInit); + + const result = await app.fetch(fetchRequest); + response.statusCode = result.status; + result.headers.forEach((value, name) => { + response.setHeader(name, value); + }); + if (result.body) { + const buffer = Buffer.from(await result.arrayBuffer()); + response.end(buffer); + return; + } + response.end(); + }; +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + return Buffer.concat(chunks); +} diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..e7d5d46 --- /dev/null +++ b/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.6.2/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab" + }, + "linter": { + "enabled": true, + "rules": { + "preset": "recommended" + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5af88b7 --- /dev/null +++ b/bun.lock @@ -0,0 +1,1389 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "muddle", + "dependencies": { + "@eslint/js": "^10.0.1", + "lightningcss": "^1.33.0", + "prettier": "^3.9.10", + "sass": "^1.106.1", + "typescript-eslint": "^8.72.0", + }, + "devDependencies": { + "@muddle/amezing": "workspace:*", + "@muddle/analysis": "workspace:*", + "@muddle/astro": "workspace:*", + "@muddle/database": "workspace:*", + "@muddle/latex": "workspace:*", + "@muddle/mdx": "workspace:*", + "@muddle/optimize": "workspace:*", + "@muddle/prettier-plugin": "workspace:*", + "@muddle/tailwind": "workspace:*", + "@types/bun": "latest", + "@types/pg": "^8.15.5", + "playwright": "^1.64.1", + }, + "peerDependencies": { + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2", + }, + }, + "modules/amezing": { + "name": "@muddle/amezing", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/analysis": { + "name": "@muddle/analysis", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*", + "playwright": "^1.0.0", + }, + }, + "modules/astro": { + "name": "@muddle/astro", + "version": "0.1.0", + "dependencies": { + "@astrojs/compiler": "2", + "astro": "5", + "typescript": "5.9.3", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/database": { + "name": "@muddle/database", + "version": "0.1.0", + "dependencies": { + "pg": "^8.23.1", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/latex": { + "name": "@muddle/latex", + "version": "0.1.0", + "dependencies": { + "katex": "^0.19.0", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/mdx": { + "name": "@muddle/mdx", + "version": "0.1.0", + "dependencies": { + "@mdx-js/mdx": "^3.1.1", + "react": "^19.3.1", + "react-dom": "^19.3.1", + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/optimize": { + "name": "@muddle/optimize", + "version": "0.1.0", + "dependencies": { + "@types/csso": "^5.0.5", + "@types/html-minifier-terser": "^7.0.2", + "csso": "^5.0.5", + "html-minifier-terser": "^7.2.0", + "svgo": "^4.1.0", + "terser": "^5.51.2", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + "modules/prettier-plugin": { + "name": "@muddle/prettier-plugin", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*", + "prettier": "^3.0.0", + }, + }, + "modules/tailwind": { + "name": "@muddle/tailwind", + "version": "0.1.0", + "dependencies": { + "tailwindcss": "^4.3.3", + }, + "peerDependencies": { + "muddle": "*", + }, + }, + }, + "packages": { + "@astrojs/compiler": ["@astrojs/compiler@2.13.1", "", {}, "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg=="], + + "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.6", "", {}, "sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q=="], + + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.11", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.6", "@astrojs/prism": "3.3.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remark-smartypants": "^3.0.2", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.2", "vfile": "^6.0.3" } }, "sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ=="], + + "@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], + + "@astrojs/telemetry": ["@astrojs/telemetry@3.3.0", "", { "dependencies": { "ci-info": "^4.2.0", "debug": "^4.4.0", "dlv": "^1.1.3", "dset": "^3.1.4", "is-docker": "^3.0.0", "is-wsl": "^3.1.0", "which-pm-runs": "^1.1.0" } }, "sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.9", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], + + "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], + + "@capsizecss/unpack": ["@capsizecss/unpack@4.0.1", "", { "dependencies": { "fontkitten": "^1.0.3" } }, "sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="], + + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/source-map": ["@jridgewell/source-map@0.3.11", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25" } }, "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], + + "@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="], + + "@muddle/amezing": ["@muddle/amezing@workspace:modules/amezing"], + + "@muddle/analysis": ["@muddle/analysis@workspace:modules/analysis"], + + "@muddle/astro": ["@muddle/astro@workspace:modules/astro"], + + "@muddle/database": ["@muddle/database@workspace:modules/database"], + + "@muddle/latex": ["@muddle/latex@workspace:modules/latex"], + + "@muddle/mdx": ["@muddle/mdx@workspace:modules/mdx"], + + "@muddle/optimize": ["@muddle/optimize@workspace:modules/optimize"], + + "@muddle/prettier-plugin": ["@muddle/prettier-plugin@workspace:modules/prettier-plugin"], + + "@muddle/tailwind": ["@muddle/tailwind@workspace:modules/tailwind"], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="], + + "@parcel/watcher": ["@parcel/watcher@2.6.0", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.4" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.6.0", "@parcel/watcher-darwin-arm64": "2.6.0", "@parcel/watcher-darwin-x64": "2.6.0", "@parcel/watcher-freebsd-x64": "2.6.0", "@parcel/watcher-linux-arm-glibc": "2.6.0", "@parcel/watcher-linux-arm-musl": "2.6.0", "@parcel/watcher-linux-arm64-glibc": "2.6.0", "@parcel/watcher-linux-arm64-musl": "2.6.0", "@parcel/watcher-linux-x64-glibc": "2.6.0", "@parcel/watcher-linux-x64-musl": "2.6.0", "@parcel/watcher-win32-arm64": "2.6.0", "@parcel/watcher-win32-x64": "2.6.0" } }, "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.6.0", "", { "os": "android", "cpu": "arm64" }, "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.6.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.6.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.6.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.6.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.6.0", "", { "os": "linux", "cpu": "arm" }, "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.6.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.6.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.6.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.6.0", "", { "os": "linux", "cpu": "x64" }, "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.6.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.6.0", "", { "os": "win32", "cpu": "x64" }, "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A=="], + + "@rollup/pluginutils": ["@rollup/pluginutils@5.5.1", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-P9ZvnfouRu4MSC+ilERaw6FmZs8zqKhpHxEs4aGDGWQAMzQGXnhvCSvoNwokfNvdt7t3Y5Y4p6gStQgezeIqug=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.65.0", "", { "os": "android", "cpu": "arm" }, "sha512-t2AnVba25baFdq7gczzS13JyVcdubLVbFX1X04mxrggDrTQPUXNv3XM0k9H1hr5tqvg7AVvtQ720QHAj0iYqhg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.65.0", "", { "os": "android", "cpu": "arm64" }, "sha512-dNONUaEwPGhl/nuO2KV8VABWlcjYyU6KtZGN8dQv5OS5JU7JSCxPijVNKXbRJd0iuTzTiJ4TjPt/4bbvDRNiMQ=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.65.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zWQqUH+9zavr2Bxuzfpuv2SIF3X31cr58TvSQC9o1GhpOvkqRwr60GkZdQcudyT/BEgdpdSafjgd2I13MbTL7g=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.65.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RQYreBFWtO4//hIuOaZkYUnF/lPU4lz35dveDimDshWCJvVisVtJ/shmT9bkXK8wFJY+FJF89X6F3Cq1qI3LCw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.65.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-A0NfMj6Cuh/9BE5k3awJKv6HoxFVXIcLAb6SZqAMoxUHPA1ZNNK4RsGuUPD/vL7nEAm9LG+ZzXWWTrnC0XtkpA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.65.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BI6/H7q3aP0I7S7GOrjyxTjgEJlSnovXE38+bdxBpuKN6z+d//uF+T+VQ3Q7ltJL/71p17ufartARX9Ja5gOGQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.65.0", "", { "os": "linux", "cpu": "arm" }, "sha512-W1vUprf09RWaVWnsFP1vlUkEWJzTZlpObOEAYZXt9Fq0UVAOYEkeGr7wBX7McKqqIQskXdBnpiOMgwPujordgA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.65.0", "", { "os": "linux", "cpu": "arm" }, "sha512-s8QTcZWgrl9haFzEGIqyE5BWyo89pFDGLMBFZ752WYxk8DJwNU/O1X6pZgPw829d9/sm6CCHyrMP3aurt76p1Q=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.65.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ke1f6dLRzkqveyjjF2fkEwQYZvxHtNNwjegY8nJrwcO0Bdqr8CzFO/pKE0Bns13cx0qz2Ba2Y0Hs3dSwMdnHxA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.65.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-926SMeUcy4y18Rz/w2aJiUKOnyAnm6czg72bXkQEaeemsll+Wr0hnsjb2BGRpHvWgwAMYy5ACJCotbvfLCfj0A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-6T7JNRP1hc4hqF2dUsEoVLOt9K8tmODoCLldtQRYnrddGGQY8Gqe2pM/qMMUo7EMowpP7oa2N95k0/8sYK/ipw=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-SImGFl1Je5kpyAgOCPONVT7h6ASV8mAUwGRjY7eZvVjPg//PfHOXgI+P241RF/+HqsA4PP3qD527ysWFKNQyhQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.65.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XeDTbmPF1Yu5asfXGPrMkWoxTDhgryQbmw5Q1+R582O51VNCk23J6f2pDrsAp5YkneBVBwtExx6ZGOb9D2aQ6A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.65.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-VS55JUI/1nsljmV/jfqNkLZXFw8uMO8nNlfoHmSevNf6fO3neuhyDvx9dv3c/Ytt8r/431SL8Qax1dpYvOMZTg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-Vblw84TbX7RS4la1Y4+yQ7aore/oSyaNtFbppuBr5Vs/4LfBeXKq9dIfoExw6zOvRERqd2rRAjnZRaMhH/0x5w=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.65.0", "", { "os": "linux", "cpu": "none" }, "sha512-7+gHjtONvKgwR9InTKFaLq53f6YzBJkgeMRBENdWTsQr+spahGqQm8HF5aXpq8i4JaRrOqYyv6Zn1WltDTxaZQ=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.65.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-KvNDC0J6s4vVHhtJ6KrDXU0Er0au/hmRIBq/+1maCV8TseGupeKizq0bWcrbp2HE1T8xoknvN0Q/KZ/YF0qX3w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.65.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GExdD15WPvu/x/drELjVam7vX8EsPgvTMOa/iJL/p/4m/oOqzbb7JXZg8fXR4hlXtg/sF2zyTZ3bR0AEtEMlhQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.65.0", "", { "os": "linux", "cpu": "x64" }, "sha512-8sT9PemIZfIMED4Vd4KGoOXVDj5CMSWNcGdbheRUx9TYS03yOWdT2Q0lXPyEyNBB9YTilwewIQKNIBk6Nxi0Kg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.65.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-pO6Ehi0jo5yE0R6PltKhJhv9mlIiyzBJk5N5NUF2OF6xtVKI+1bdXxj/rb0jGXy3hck1ELVT0QPY3tzkAm360w=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.65.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vo7N3/+cjjPDYr8vtxC+qSNgWa9sDvJ7g8N8R5SWqFY4WiNH1ULcAU70SKVyHeiIZOYYwNP9pTipmhN5sl1NPw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.65.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-ArwvgFAbjKcDM7qqotZ4OVYf+UMT9xH2YzkCzK4+AuZSDihMlz1Ekoe4INwBVXzn2mnJzuIYOcSx07iTBijGRQ=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.65.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-QGMmK4/cxKE/36FoLMSHQBCyymv/eFTnsCvvsBF0rgmOvVbvX78VWZRerL3dHa77f4pM6Xcc2iGDhJ10w/svKQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-XfKYFWHIPs4HPODIMsGF1IKlS3H1e3WokUZIgpmn4+uNnZBlnRDDHEylNyazbQXwhMKZre68OjtRYbKqb5+j8w=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.65.0", "", { "os": "win32", "cpu": "x64" }, "sha512-wPWi++K4Bs1VR4XihOhfGi4IGX6Cub7FlerTf8X2v+2BtDHddgSqWCXwGD+h2yAe4/niIhNDwAltioOC+wEogQ=="], + + "@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g=="], + + "@shikijs/langs": ["@shikijs/langs@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg=="], + + "@shikijs/themes": ["@shikijs/themes@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0" } }, "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA=="], + + "@shikijs/types": ["@shikijs/types@3.23.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.1.0", "", {}, "sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ=="], + + "@types/bun": ["@types/bun@1.4.3", "", { "dependencies": { "bun-types": "1.4.3" } }, "sha512-N67Pqy8JXbAY/j0tNraS3V5mubGvc6rrrNDmDCtZLlFzccbUJPDb0tvgUBC5q4m8ybkP93Jcn1QB+RYQ6PvBhw=="], + + "@types/css-tree": ["@types/css-tree@2.3.11", "", {}, "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg=="], + + "@types/csso": ["@types/csso@5.0.5", "", { "dependencies": { "@types/css-tree": "^2" } }, "sha512-Et/B3W1/1XMjG9SxwitiokWDabA9N8otVoTBJF2RmZ8itU0c5h7qmuZcTpp17BqfAnsdfkGQ3hwizff3nJvWSQ=="], + + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], + + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/html-minifier-terser": ["@types/html-minifier-terser@7.0.2", "", {}, "sha512-mm2HqV22l8lFQh4r2oSsOEVea+m0qqxEmwpc9kC1p/XzmjLWrReR9D/GRs8Pex2NX/imyEH9c5IU/7tMBQCHOA=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdx": ["@types/mdx@2.0.14", "", {}, "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/nlcst": ["@types/nlcst@2.0.3", "", { "dependencies": { "@types/unist": "*" } }, "sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA=="], + + "@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="], + + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + + "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="], + + "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.72.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/type-utils": "8.72.0", "@typescript-eslint/utils": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.72.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.72.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.72.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.72.0", "@typescript-eslint/types": "^8.72.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0" } }, "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.72.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/utils": "8.72.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.72.0", "", {}, "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.72.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.72.0", "@typescript-eslint/tsconfig-utils": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/visitor-keys": "8.72.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.72.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.72.0", "@typescript-eslint/types": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.72.0", "", { "dependencies": { "@typescript-eslint/types": "8.72.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA=="], + + "@typescript/native": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], + + "@typescript/old": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.4.0", "", {}, "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="], + + "acorn": ["acorn@8.19.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], + + "ansi-regex": ["ansi-regex@6.4.0", "", {}, "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw=="], + + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "array-iterate": ["array-iterate@2.0.1", "", {}, "sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg=="], + + "astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="], + + "astro": ["astro@5.18.2", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/internal-helpers": "0.7.6", "@astrojs/markdown-remark": "6.3.11", "@astrojs/telemetry": "3.3.0", "@capsizecss/unpack": "^4.0.0", "@oslojs/encoding": "^1.1.0", "@rollup/pluginutils": "^5.3.0", "acorn": "^8.15.0", "aria-query": "^5.3.2", "axobject-query": "^4.1.0", "boxen": "8.0.1", "ci-info": "^4.3.1", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^1.1.1", "cssesc": "^3.0.0", "debug": "^4.4.3", "deterministic-object-hash": "^2.0.2", "devalue": "^5.6.2", "diff": "^8.0.3", "dlv": "^1.1.3", "dset": "^3.1.4", "es-module-lexer": "^1.7.0", "esbuild": "^0.27.3", "estree-walker": "^3.0.3", "flattie": "^1.1.1", "fontace": "~0.4.0", "github-slugger": "^2.0.0", "html-escaper": "3.0.3", "http-cache-semantics": "^4.2.0", "import-meta-resolve": "^4.2.0", "js-yaml": "^4.1.1", "magic-string": "^0.30.21", "magicast": "^0.5.1", "mrmime": "^2.0.1", "neotraverse": "^0.6.18", "p-limit": "^6.2.0", "p-queue": "^8.1.1", "package-manager-detector": "^1.6.0", "piccolore": "^0.1.3", "picomatch": "^4.0.3", "prompts": "^2.4.2", "rehype": "^13.0.2", "semver": "^7.7.3", "shiki": "^3.21.0", "smol-toml": "^1.6.0", "svgo": "^4.0.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tsconfck": "^3.1.6", "ultrahtml": "^1.6.0", "unifont": "~0.7.3", "unist-util-visit": "^5.0.0", "unstorage": "^1.17.4", "vfile": "^6.0.3", "vite": "^6.4.1", "vitefu": "^1.1.1", "xxhash-wasm": "^1.1.0", "yargs-parser": "^21.1.1", "yocto-spinner": "^0.2.3", "zod": "^3.25.76", "zod-to-json-schema": "^3.25.1", "zod-to-ts": "^1.2.0" }, "optionalDependencies": { "sharp": "^0.34.0" }, "bin": { "astro": "astro.js" } }, "sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ=="], + + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="], + + "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], + + "boxen": ["boxen@8.0.1", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^8.0.0", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "string-width": "^7.2.0", "type-fest": "^4.21.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0" } }, "sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], + + "bun-types": ["bun-types@1.4.3", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-tinRJNGOmUN+tEAsn2y2e942n9JHRHR42iUYzDH75NmhJqVBokGSQkkfSFdNWwsANIdrK6ACH9t01M452YyTwg=="], + + "cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="], + + "camel-case": ["camel-case@4.1.2", "", { "dependencies": { "pascal-case": "^3.1.2", "tslib": "^2.0.3" } }, "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw=="], + + "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + + "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + + "clean-css": ["clean-css@5.3.3", "", { "dependencies": { "source-map": "~0.6.0" } }, "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg=="], + + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + + "common-ancestor-path": ["common-ancestor-path@1.0.1", "", {}, "sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-es": ["cookie-es@1.2.3", "", {}, "sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], + + "css-select": ["css-select@6.0.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^7.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "nth-check": "^2.1.1" } }, "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw=="], + + "css-tree": ["css-tree@2.2.1", "", { "dependencies": { "mdn-data": "2.0.28", "source-map-js": "^1.0.1" } }, "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA=="], + + "css-what": ["css-what@7.0.0", "", {}, "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csso": ["csso@5.0.5", "", { "dependencies": { "css-tree": "~2.2.0" } }, "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "deterministic-object-hash": ["deterministic-object-hash@2.0.2", "", { "dependencies": { "base-64": "^1.0.0" } }, "sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ=="], + + "devalue": ["devalue@5.9.4", "", {}, "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="], + + "domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="], + + "domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="], + + "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], + + "dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="], + + "dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="], + + "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], + + "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@10.13.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="], + + "estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "estree-util-scope": ["estree-util-scope@1.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA=="], + + "estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="], + + "estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + + "flattie": ["flattie@1.1.1", "", {}, "sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ=="], + + "fontace": ["fontace@0.4.1", "", { "dependencies": { "fontkitten": "^1.0.2" } }, "sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw=="], + + "fontkitten": ["fontkitten@1.0.3", "", { "dependencies": { "tiny-inflate": "^1.0.3" } }, "sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="], + + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="], + + "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], + + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], + + "html-minifier-terser": ["html-minifier-terser@7.2.0", "", { "dependencies": { "camel-case": "^4.1.2", "clean-css": "~5.3.2", "commander": "^10.0.0", "entities": "^4.4.0", "param-case": "^3.0.4", "relateurl": "^0.2.7", "terser": "^5.15.1" }, "bin": { "html-minifier-terser": "cli.js" } }, "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-cache-semantics": ["http-cache-semantics@4.3.0", "", {}, "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immutable": ["immutable@5.1.9", "", {}, "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg=="], + + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "iron-webcrypto": ["iron-webcrypto@1.2.1", "", {}, "sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-yaml": ["js-yaml@4.3.2", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "katex": ["katex@0.19.0", "", { "dependencies": { "commander": "^15.0.0" }, "bin": { "katex": "cli.js" } }, "sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q=="], + + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="], + + "lru-cache": ["lru-cache@11.5.3", "", {}, "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "magicast": ["magicast@0.5.5", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA=="], + + "markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "mdast-util-definitions": ["mdast-util-definitions@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.2.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-character": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="], + + "micromark": ["micromark@4.0.3", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.4", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.1.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-edit-map": "^1.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.3" } }, "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.2", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="], + + "micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="], + + "micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="], + + "micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="], + + "micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="], + + "micromark-factory-space": ["micromark-factory-space@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-edit-map": ["micromark-util-edit-map@1.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.3", "", {}, "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "muddle": ["muddle@1.0.0", "", {}, "sha512-fcfBNyWEuTQWexBVuXTnbdrZsRJ7w/C5kUEp1NAXnaNeegW2sfYDOfbU9ItCzflFR/ijC32xKn/D20Ky0DTwVg=="], + + "nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "neotraverse": ["neotraverse@0.6.18", "", {}, "sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA=="], + + "nlcst-to-string": ["nlcst-to-string@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0" } }, "sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA=="], + + "no-case": ["no-case@3.0.4", "", { "dependencies": { "lower-case": "^2.0.2", "tslib": "^2.0.3" } }, "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], + + "node-mock-http": ["node-mock-http@1.0.5", "", {}, "sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], + + "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], + + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@6.2.0", "", { "dependencies": { "yocto-queue": "^1.1.1" } }, "sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-queue": ["p-queue@8.1.1", "", { "dependencies": { "eventemitter3": "^5.0.1", "p-timeout": "^6.1.2" } }, "sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ=="], + + "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], + + "package-manager-detector": ["package-manager-detector@1.9.0", "", {}, "sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA=="], + + "param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + + "piccolore": ["piccolore@0.1.3", "", {}, "sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "playwright": ["playwright@1.64.1", "", { "dependencies": { "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" } }, "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA=="], + + "playwright-core": ["playwright-core@1.64.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ=="], + + "postcss": ["postcss@8.5.31", "", { "dependencies": { "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.9.10", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA=="], + + "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + + "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], + + "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], + + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + + "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], + + "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], + + "recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="], + + "recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype": ["rehype@13.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "rehype-parse": "^9.0.0", "rehype-stringify": "^10.0.0", "unified": "^11.0.0" } }, "sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A=="], + + "rehype-parse": ["rehype-parse@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-html": "^2.0.0", "unified": "^11.0.0" } }, "sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="], + + "rehype-stringify": ["rehype-stringify@10.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-to-html": "^9.0.0", "unified": "^11.0.0" } }, "sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA=="], + + "relateurl": ["relateurl@0.2.7", "", {}, "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-smartypants": ["remark-smartypants@3.0.3", "", { "dependencies": { "retext": "^9.0.0", "retext-smartypants": "^6.0.0", "unified": "^11.0.4", "unist-util-visit": "^5.0.0" } }, "sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="], + + "retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="], + + "retext-smartypants": ["retext-smartypants@6.2.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ=="], + + "retext-stringify": ["retext-stringify@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "nlcst-to-string": "^4.0.0", "unified": "^11.0.0" } }, "sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA=="], + + "rollup": ["rollup@4.65.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.65.0", "@rollup/rollup-android-arm64": "4.65.0", "@rollup/rollup-darwin-arm64": "4.65.0", "@rollup/rollup-darwin-x64": "4.65.0", "@rollup/rollup-freebsd-arm64": "4.65.0", "@rollup/rollup-freebsd-x64": "4.65.0", "@rollup/rollup-linux-arm-gnueabihf": "4.65.0", "@rollup/rollup-linux-arm-musleabihf": "4.65.0", "@rollup/rollup-linux-arm64-gnu": "4.65.0", "@rollup/rollup-linux-arm64-musl": "4.65.0", "@rollup/rollup-linux-loong64-gnu": "4.65.0", "@rollup/rollup-linux-loong64-musl": "4.65.0", "@rollup/rollup-linux-ppc64-gnu": "4.65.0", "@rollup/rollup-linux-ppc64-musl": "4.65.0", "@rollup/rollup-linux-riscv64-gnu": "4.65.0", "@rollup/rollup-linux-riscv64-musl": "4.65.0", "@rollup/rollup-linux-s390x-gnu": "4.65.0", "@rollup/rollup-linux-x64-gnu": "4.65.0", "@rollup/rollup-linux-x64-musl": "4.65.0", "@rollup/rollup-openbsd-x64": "4.65.0", "@rollup/rollup-openharmony-arm64": "4.65.0", "@rollup/rollup-win32-arm64-msvc": "4.65.0", "@rollup/rollup-win32-ia32-msvc": "4.65.0", "@rollup/rollup-win32-x64-gnu": "4.65.0", "@rollup/rollup-win32-x64-msvc": "4.65.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-QVCvQUTGDN6JRwi3jVGCY3H4aTHvi9rX0LyoBMkGUvdh/Z6O51CzcLz85BWZaTCDGhl6ySGqYZs+xI1jmPqGcw=="], + + "sass": ["sass@1.106.1", "", { "dependencies": { "chokidar": "^5.0.0", "immutable": "^5.1.5", "source-map-js": ">=0.6.2 <2.0.0" }, "optionalDependencies": { "@parcel/watcher": "^2.4.1" }, "bin": { "sass": "sass.js" } }, "sha512-kD+Gi66vycZcbJt+42NC4z4PXNMcCajVrJPhqoKKXy5cOBMmJQt052CxIBcRjwYXeImHYOpu/ydWWGZQIenl7A=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "smol-toml": ["smol-toml@1.9.1", "", {}, "sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="], + + "source-map-support": ["source-map-support@0.5.21", "", { "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" } }, "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "svgo": ["svgo@4.1.0", "", { "dependencies": { "commander": "^11.1.0", "css-select": "^6.0.0", "css-tree": "^3.0.1", "css-what": "^7.0.0", "csso": "^5.0.5", "picocolors": "^1.1.1", "sax": "1.6.1" }, "bin": { "svgo": "bin/svgo.js" } }, "sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q=="], + + "tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="], + + "terser": ["terser@5.51.2", "", { "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", "commander": "^2.20.0", "source-map-support": "~0.5.20" }, "bin": { "terser": "bin/terser" } }, "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q=="], + + "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], + + "tinyexec": ["tinyexec@1.3.1", "", {}, "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="], + + "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + + "typescript": ["@typescript/typescript6@6.0.2", "", { "dependencies": { "@typescript/old": "npm:typescript@^6" }, "bin": { "tsc6": "bin/tsc6" } }, "sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w=="], + + "typescript-eslint": ["typescript-eslint@8.72.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.72.0", "@typescript-eslint/parser": "8.72.0", "@typescript-eslint/typescript-estree": "8.72.0", "@typescript-eslint/utils": "8.72.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "ultrahtml": ["ultrahtml@1.7.0", "", {}, "sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g=="], + + "uncrypto": ["uncrypto@0.1.3", "", {}, "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q=="], + + "undici": ["undici@8.12.3", "", {}, "sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw=="], + + "undici-types": ["undici-types@8.12.3", "", {}, "sha512-EXE4ydL3S76dFb27UFjNVZHTyv/TLdATj/KUe29ptdcg6qXL48J2tZ3126E5kICj5jn781OR+XlVH1N6Mz8jbA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unifont": ["unifont@0.7.5", "", { "dependencies": { "css-tree": "^3.1.0", "ohash": "^2.0.11", "undici": "^8.0.0" } }, "sha512-ULe/Cs+ZIsq+dcFofNkhqielCrUJnb5mr+Yc4EBM2VlL+6OZR6+cjtI2mT1bJvRBrVncqHAbLURxmPLcCXzWMg=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-modify-children": ["unist-util-modify-children@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "array-iterate": "^2.0.0" } }, "sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-children": ["unist-util-visit-children@3.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "unstorage": ["unstorage@1.17.6", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^5.0.0", "destr": "^2.0.5", "h3": "^1.15.11", "lru-cache": "^11.5.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.4" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6 || ^7 || ^8", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1 || ^2 || ^3", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@6.4.5", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg=="], + + "vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "which-pm-runs": ["which-pm-runs@1.1.0", "", {}, "sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA=="], + + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "yocto-spinner": ["yocto-spinner@0.2.3", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ=="], + + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@muddle/astro/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.13", "", {}, "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q=="], + + "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + + "clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "p-locate/p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], + + "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "svgo/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "svgo/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "unifont/css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "svgo/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "unifont/css-tree/mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "vite/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "vite/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/cli.ts b/cli.ts new file mode 100755 index 0000000..54cdfa9 --- /dev/null +++ b/cli.ts @@ -0,0 +1,123 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { generate, collectTables } from "./contracts/typegen/generator.ts"; +import { format } from "./core/format.ts"; +import { loadConfig } from "./core/config.ts"; +import { setupWrangler } from "./adapters/cloudflare-wrangler.ts"; +import { setupVercel } from "./adapters/vercel-vercel.ts"; +import { backportProject } from "./core/backport.ts"; +import { createTemplate, muddleTemplates, type MuddleTemplate } from "./core/templates.ts"; + +function progress(event: { current: number; total: number; route: string }): void { + const width = 20; + const filled = Math.round((event.current / Math.max(1, event.total)) * width); + console.log(`[${"#".repeat(filled)}${".".repeat(width - filled)}] ${event.current}/${event.total} ${event.route}`); +} + +async function dev(entry: string, root: string, port: number): Promise { + const moduleUrl = pathToFileURL(entry).href; + const source = `const project = await import(${JSON.stringify(moduleUrl)}); if (!project.app || typeof project.app.fetch !== "function") throw new Error("Project entry must export an app with fetch(request)"); const server = Bun.serve({ port: ${port}, fetch: (request) => project.app.fetch(request) }); console.log(\`muddle dev listening on \${server.url}\`); await new Promise(() => {});`; + const child = Bun.spawn([process.execPath, "--watch", "--eval", source], { + cwd: root, + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + const stop = () => child.kill(); + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + await child.exited; +} + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0] ?? "help"; + const portFlag = args.indexOf("--port"); + const requestedPort = portFlag === -1 ? undefined : Number(args[portFlag + 1]); + const root = args.slice(1).find((argument, index, values) => { + if (argument.startsWith("--")) return false; + return values[index - 1] !== "--port"; + }) ?? process.cwd(); + if (requestedPort !== undefined && (!Number.isInteger(requestedPort) || requestedPort < 1 || requestedPort > 65_535)) { + throw new Error("--port must be an integer from 1 through 65535"); + } + if (command === "typegen") { + const result = await generate({ root }); + for (const file of result.written) console.log(`muddle generated ${relative(process.cwd(), file)}`); + return; + } + if (command === "backport") { + const source = args[1] ? resolve(args[1]) : process.cwd(); + const destination = args[2] ? resolve(args[2]) : resolve(source, "muddle"); + const result = await backportProject(source, destination); + console.log(`muddle backported ${result.source} project to ${result.output} (${result.files.length} files)`); + return; + } + if (command === "templates") { + const name = args[1] as MuddleTemplate | undefined; + if (!name) { + console.log(`Available templates: ${muddleTemplates.join(", ")}`); + console.log("Usage: muddle templates [destination]"); + return; + } + if (!muddleTemplates.includes(name)) throw new Error(`Unknown template ${name}. Choose one of: ${muddleTemplates.join(", ")}`); + const result = await createTemplate(name, args[2] ? resolve(args[2]) : resolve(process.cwd(), name)); + console.log(`muddle created ${result.name} template at ${result.output} (${result.files.length} files)`); + return; + } + if (command === "repo") { + const target = resolve(root); + const tables = await collectTables(target, ["**/*.table.ts"]); + const out = join(target, "repositories"); + await mkdir(out, { recursive: true }); + for (const table of tables) { + const rowType = table.fields + .map((field) => { + const tsType = field.type; + const optional = field.nullable ? "?" : ""; + return `\t${field.property}${optional}: ${tsType};`; + }) + .join("\n"); + const typeName = `${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Row`; + const source = `import { Repository } from "@muddle/database";\n\nexport type ${typeName} = {\n${rowType}\n};\n\nexport class ${table.name[0]?.toUpperCase() ?? ""}${table.name.slice(1)}Repository extends Repository<${typeName}> {}\n`; + const file = join(out, `${table.name}.repository.ts`); + await writeFile(file, source); + console.log(`muddle generated ${relative(target, file)}`); + } + return; + } + if (command === "fmt") { + const check = process.argv.includes("--check"); + const changed = await format(resolve(root === "--check" ? process.cwd() : root), { check }); + for (const file of changed) console.log(`${check ? "Unformatted" : "Formatted"} ${file}`); + if (check && changed.length) process.exitCode = 1; + return; + } + if (command === "build" || command === "dev") { + const target = resolve(root); + const config = await loadConfig(target); + const entry = resolve(target, config.entry ?? "index.ts"); + if (!existsSync(entry)) throw new Error(`No index.ts found in ${root}`); + if (config.adapter?.name === "cloudflare-wrangler") await setupWrangler(target); + if (config.adapter?.name === "vercel-vercel") await setupVercel(target); + const project = await import(entry) as { build?: (options?: { progress?: typeof progress }) => Promise; app?: unknown }; + if (command === "build") { + if (!project.build) throw new Error("Project must export build() for muddle build"); + await project.build({ progress }); + return; + } else if (command === "dev") { + if (!project.app || typeof (project.app as { fetch?: unknown }).fetch !== "function") { + throw new Error("Project entry must export an app with fetch(request) for muddle dev"); + } + await dev(entry, target, requestedPort ?? config.adapter?.port ?? 3000); + return; + } + return; + } + console.log("muddle [project-root] [--port number]"); +} + +await main(); diff --git a/contracts/index.ts b/contracts/index.ts new file mode 100644 index 0000000..a07cc70 --- /dev/null +++ b/contracts/index.ts @@ -0,0 +1,229 @@ +import type { RequestContext } from "./typegen/http/request.ts"; + +export * from "./typegen/http/index.ts"; +export * from "./typegen/orm/index.ts"; +export * from "./typegen/minify.ts"; +export type { ViewPath, ViewProps, ViewPropsOf } from "./typegen/views.ts"; + +/** A file handled by a loader. */ +export interface LoaderFile { + /** Absolute path of the file. */ + path: string; + /** Path relative to the project root. */ + relativePath: string; + /** Raw content of the file. */ + source: string; +} + +/** Result of a loader. */ +export interface LoadedFile { + /** Data exposed to the application, e.g. frontmatter and rendered html. */ + data: Record; + /** Rendered representation, e.g. the compiled css or the markdown html. */ + content?: string; +} + +export interface Loader { + /** Name of the loader, used in error messages. */ + name: string; + /** File extensions handled by the loader, e.g. `['.md']`. */ + extensions: string[]; + /** Loads a single file. */ + load(file: LoaderFile): Promise | LoadedFile; +} + +/** Options shared by all loaders. */ +export interface LoaderOptions { + /** Directory the loader scans, relative to the project root. */ + dir?: string; +} + +/** Configuration of the content collections. */ +export interface CollectionConfig { + /** Name of the collection, e.g. `notes`. */ + name: string; + /** Directory of the collection, relative to the project root. */ + dir: string; + /** Loader used to read the files of the collection, by name or inline. */ + loader: Loader | string; + /** Schema of the frontmatter, validated at load time. */ + schema?: ContentSchema; +} + +/** Schema of a content collection. */ +export interface ContentSchema { + /** Required frontmatter fields. */ + required?: string[]; + /** Allowed values of a field. */ + enum?: Record; + /** Maximum length of a string field. */ + maxLength?: Record; +} + +/** A single entry of a content collection. */ +export interface CollectionEntry< + Frontmatter extends Record = Record, +> { + /** Identifier of the entry, derived from the file name. */ + id: string; + /** Path of the file, relative to the project root. */ + path: string; + /** Parsed frontmatter. */ + frontmatter: Frontmatter; + /** Rendered body. */ + body: string; + /** Raw markdown source. */ + content: string; +} + +/** Minimal view registry handed to integration mounts. */ +export interface MountableViews { + /** Registers a named view component. */ + component( + name: string, + component: { render: (props: unknown, context: unknown) => string | Promise }, + ): unknown; +} + +/** A self-contained unit an integration mounts into the application. */ +export interface Mounts { + /** Registers view components, e.g. the primitives of a theme. */ + views?(views: MountableViews): void | Promise; + /** Adds routes, e.g. assets, fonts and endpoints. */ + routes?( + route: (method: string, path: string, handler: (request: Request) => Response | Promise) => void, + ): void | Promise; + /** Served inline stylesheets by route. */ + styles?: Record string | Promise)>; +} + +/** Integration hook, called while the application is created. */ +export interface Integration { + /** Transforms a staged build asset. Promises run in configured order. */ + transform?(asset: BuildAsset): BuildAsset | Promise; + /** Analyzes a fully rendered page before the output is published. */ + analyze?(page: { route: string; html: string }): unknown | Promise; + /** Runs before routing, including raw integration endpoints. */ + request?(context: RequestContext): void | Promise; + /** Name of the integration. */ + name: string; + /** Called with the resolved configuration. */ + setup?(context: IntegrationContext): void | Promise; + /** Full-stack units mounted while the application boots. */ + mounts?: Mounts[]; + /** Called before the server starts serving requests. */ + ready?(): void | Promise; +} + +export interface BuildAsset { + path: string; + contentType: string; + content: string | Uint8Array; +} + +/** Context handed to an integration. */ +export interface IntegrationContext { + /** Resolved project configuration. */ + config: ResolvedConfig; + /** Adds a route to the application. */ + route( + method: string, + path: string, + handler: ( + request: Request, + context: RequestContext, + ) => Promise | Response, + ): void; + /** Adds a loader to the project. */ + loader(name: string, loader: Loader): void; +} + +/** Adapter configuration, e.g. the port of the http server. */ +export interface AdapterOptions { + /** Port the server listens on. */ + port?: number; + /** Hostname the server binds to. */ + hostname?: string; + /** Name of the adapter, e.g. `server-bun`. */ + name?: string; +} + +/** Database configuration. */ +export interface DatabaseOptions { + /** Dialect of the database. */ + dialect: "sqlite" | "postgres" | "d1"; + /** Path of the sqlite file or the connection string of postgres. */ + url?: string; + /** Cloudflare D1 binding configuration. */ + d1?: { accountId: string; databaseId: string; apiToken: string }; +} + +/** Prerendering configuration. */ +export interface PrerenderOptions { + /** Routes rendered at build time. */ + routes: string[]; + /** Directory the html files are written to. */ + outDir?: string; + /** Writes every route, even when the handler answers with a redirect. */ + fallback?: boolean; +} + +/** Project configuration, as written in `ct.config.ts`. */ +export interface MuddleConfig { + /** Root directory of the project. */ + root?: string; + /** Server entry module, relative to the project root. Defaults to `index.ts`. */ + entry?: string; + /** Directory with the templates. */ + views?: string; + /** Directory with the components. */ + components?: string; + /** Directory with the markdown collections. */ + content?: string; + /** Directory with the structured css files. */ + styles?: string; + /** Directory with static assets. */ + publicDir?: string; + /** Directories scanned for templates, relative to the project root. */ + viewRoots?: string[]; + /** Loaders available to the content collections. */ + loaders?: Record; + /** Content collections of the project. */ + collections?: CollectionConfig[]; + /** Integrations of the project. */ + integrations?: Integration[]; + /** Prerendering configuration. */ + prerender?: PrerenderOptions; + /** Adapter used to serve the application. */ + adapter?: AdapterOptions; + /** Database configuration. */ + database?: DatabaseOptions; + /** Model files of the project, relative to the project root. */ + tables?: string[]; + /** Where the generated contracts are written to. */ + typegen?: { out?: string }; +} + +/** Configuration with every default applied. */ +export interface ResolvedConfig extends MuddleConfig { + root: string; + views: string; + components: string; + content: string; + styles: string; + publicDir: string; + viewRoots: string[]; + loaders: Record; + collections: CollectionConfig[]; + integrations: Integration[]; +} + +/** + * Declares the configuration of a project. + * + * The helper only exists for the editor: it checks the configuration while + * it is written in `ct.config.ts`. + */ +export function defineConfig(config: MuddleConfig): MuddleConfig { + return config; +} diff --git a/contracts/typegen/generator.ts b/contracts/typegen/generator.ts new file mode 100644 index 0000000..19a7187 --- /dev/null +++ b/contracts/typegen/generator.ts @@ -0,0 +1,427 @@ +#!/usr/bin/env bun +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { basename, join, relative, resolve } from "node:path"; +import { lex } from "../../views/engine/lexer.ts"; +import { collectProps, parse } from "../../views/engine/parser.ts"; +import type { Node, PropDeclaration } from "../../views/engine/types.ts"; +import { compactTypeSource } from "./minify.ts"; +import { getMetadata } from "../../core/metadata.ts"; +import type { MuddleConfig } from "../index.ts"; + +export interface GeneratorOptions { + /** Root directory of the project. */ + root?: string; + /** Directory the generated contracts are written to. */ + out?: string; + /** Glob like patterns of the model files, relative to the root. */ + tables?: string[]; + /** Directories scanned for templates, relative to the root. */ + viewRoots?: string[]; +} + +export interface ViewContract { + /** Key of the contract, relative to the project root. */ + key: string; + /** Absolute path of the template. */ + path: string; + /** Props declared by the template. */ + props: PropDeclaration[]; +} + +export interface GeneratorResult { + /** Contracts of the views. */ + views: ViewContract[]; + /** Contracts of the tables. */ + tables: { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[]; + /** Files written by the generator. */ + written: string[]; +} + +/** Default directory of contracts generated for a project. */ +export const defaultOut = "_muddle/contracts"; + +/** Recursively lists the pug files of a directory. */ +export async function listTemplates(dir: string): Promise { + if (!existsSync(dir)) return []; + const entries = await readdir(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTemplates(path))); + continue; + } + if (entry.name.endsWith(".spug")) files.push(path); + } + return files; +} + +/** Reads the props of a template. */ +export async function readTemplate(path: string): Promise { + const source = await readFile(path, "utf8"); + const parsed = parse(lex(source)); + return collectProps(parsed.nodes); +} + +/** Collects the component usages of a template. */ +export async function readComponentUsages( + path: string, +): Promise<{ name: string; props: Record }[]> { + const source = await readFile(path, "utf8"); + const parsed = parse(lex(source)); + const usages: { name: string; props: Record }[] = []; + walk(parsed.nodes, (node) => { + if (node.kind === "component" || node.kind === "island") { + usages.push({ name: node.name, props: node.props }); + } + }); + return usages; +} + +function walk(nodes: Node[], visit: (node: Node) => void): void { + for (const node of nodes) { + visit(node); + const children = childrenOf(node); + if (children) walk(children, visit); + } +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} + +/** Reads the configuration of a project. */ +export async function readConfig(root: string): Promise { + const configPath = join(root, "ct.config.ts"); + if (!existsSync(configPath)) return {}; + const module = (await import(configPath)) as { default?: MuddleConfig }; + return module.default ?? {}; +} + +/** Renders the props of a view as a typescript member. */ +export function renderProps(props: PropDeclaration[]): string { + if (props.length === 0) return "Record"; + const members = props.map((prop) => { + const type = compactTypeSource(prop.type || "unknown"); + const optional = prop.optional ? "?" : ""; + const fallback = prop.defaultValue ? ` = ${prop.defaultValue}` : ""; + return `${prop.name}${optional}: ${type}${fallback}`; + }); + return `{ ${members.join("; ")} }`; +} + +/** Renders the contract of the views. */ +export function renderViewContracts(views: ViewContract[]): string { + const entries = views + .map((view) => `\t${JSON.stringify(view.key)}: ${renderProps(view.props)};`) + .join("\n"); + return `// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run \`bun run typegen \` to update this file. + +import type { Minify } from "./minify.ts"; + +export interface ViewProps { +${entries} +} + +/** Path of a view of the project. */ +export type ViewPath = keyof ViewProps; + +/** Props of a single view. */ +export type ViewPropsOf

= Minify; +`; +} + +/** Maps a field type onto its typescript counterpart. */ +export function fieldTypeToTypeScript(type: string, nullable: boolean): string { + const base = (() => { + switch (type) { + case "int": + case "bigint": + case "real": + return "number"; + case "bool": + return "boolean"; + case "json": + return "unknown"; + case "blob": + return "Uint8Array"; + default: + return "string"; + } + })(); + return nullable ? `${base} | null` : base; +} + +/** Renders the contract of the tables. */ +export function renderTableContracts( + tables: { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[], +): string { + const rows = tables + .map((table) => { + const members = table.fields + .map( + (field) => + `\t${JSON.stringify(field.property)}: ${fieldTypeToTypeScript(field.type, field.nullable)};`, + ) + .join("\n"); + return `export interface ${pascalCase(table.name)}Row {\n${members}\n}`; + }) + .join("\n\n"); + + const map = tables + .map( + (table) => + `\t${JSON.stringify(table.name)}: ${pascalCase(table.name)}Row;`, + ) + .join("\n"); + + return `// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run \`bun run typegen \` to update this file. + +${rows} + +export interface TableRows { +${map} +} + +export type TableName = keyof TableRows; + +/** Row of a single table. */ +export type TableRowOf = TableRows[T]; +`; +} + +/** Converts a table name into a typescript identifier. */ +export function pascalCase(value: string): string { + return value + .split(/[^a-zA-Z0-9]+/) + .filter((part) => part.length > 0) + .map((part) => part[0]?.toUpperCase() + part.slice(1)) + .join(""); +} + +/** + * Validates the component usages of the templates. + * + * Every prop handed to a component must be declared by the component and + * every required prop of the component must be handed over. + */ +export async function validateComponents( + root: string, + views: ViewContract[], + componentsDir: string, +): Promise { + const errors: string[] = []; + const componentProps = new Map(); + + for (const file of await listTemplates(componentsDir)) { + componentProps.set(basename(file, ".spug"), await readTemplate(file)); + } + + for (const view of views) { + for (const usage of await readComponentUsages(view.path)) { + const declared = componentProps.get(usage.name); + if (!declared) continue; + const names = new Set(declared.map((prop) => prop.name)); + for (const prop of Object.keys(usage.props)) { + if (!names.has(prop)) { + errors.push( + `${relative(root, view.path)}: component "${usage.name}" has no prop "${prop}"`, + ); + } + } + for (const prop of declared) { + if (prop.optional) continue; + if (!(prop.name in usage.props)) { + errors.push( + `${relative(root, view.path)}: component "${usage.name}" requires the prop "${prop.name}"`, + ); + } + } + } + } + + return errors; +} + +/** Runs the generator. */ +export async function generate( + options: GeneratorOptions = {}, +): Promise { + const root = resolve(options.root ?? process.cwd()); + const config = await readConfig(root); + const configuredOut = options.out ?? config.typegen?.out ?? defaultOut; + const out = resolve(root, configuredOut); + + const viewRoots = ( + options.viewRoots ?? + config.viewRoots ?? [config.views ?? "views"] + ).map((entry) => resolve(root, entry)); + + const views: ViewContract[] = []; + for (const viewRoot of viewRoots) { + for (const file of await listTemplates(viewRoot)) { + views.push({ + key: relative(root, file) + .replace(/\.spug$/, "") + .replace(/\\/g, "/"), + path: file, + props: await readTemplate(file), + }); + } + } + views.sort((left, right) => left.key.localeCompare(right.key)); + + const errors = await validateComponents( + root, + views, + resolve( + root, + config.components ?? join(config.views ?? "views", "components"), + ), + ); + if (errors.length > 0) { + throw new Error( + `Invalid component usage:\n${errors.map((error) => ` - ${error}`).join("\n")}`, + ); + } + + const tables = await collectTables(root, options.tables ?? config.tables); + + await mkdir(join(out, "orm"), { recursive: true }); + const minifyFile = join(out, "minify.ts"); + await writeFile(minifyFile, `export type Simplify = { [K in keyof T]: T[K] } & {};\nexport type Minify = Simplify;\n`, "utf8"); + const viewsFile = join(out, "views.ts"); + await writeFile(viewsFile, renderViewContracts(views), "utf8"); + + const tablesFile = join(out, "orm", "tables.ts"); + await writeFile(tablesFile, renderTableContracts(tables), "utf8"); + const indexFile = join(out, "index.ts"); + await writeFile(indexFile, `export * from "./views.ts";\nexport * from "./orm/tables.ts";\nexport * from "./minify.ts";\n`, "utf8"); + + return { views, tables, written: [minifyFile, viewsFile, tablesFile, indexFile] }; +} + +/** Collects the tables of the project by importing the model files. */ +export async function collectTables( + root: string, + patterns: string[] | undefined, +): Promise< + { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[] +> { + if (!patterns || patterns.length === 0) return []; + + const files: string[] = []; + for (const pattern of patterns) { + if (pattern.includes("**")) { + const suffix = pattern.slice(pattern.lastIndexOf("*") + 1); + files.push(...(await listFiles(root, suffix))); + continue; + } + const star = pattern.indexOf("*"); + const dirPart = star === -1 ? pattern : pattern.slice(0, star); + const suffix = star === -1 ? ".ts" : pattern.slice(star + 1); + const dir = resolve(root, dirPart); + const entries = existsSync(dir) ? await readdir(dir) : []; + for (const entry of entries) { + if (!entry.endsWith(suffix)) continue; + files.push(join(dir, entry)); + } + } + + const tables: { + name: string; + fields: { + property: string; + column: string; + type: string; + nullable: boolean; + }[]; + }[] = []; + + for (const file of files) { + const module = (await import(file)) as Record; + for (const exported of Object.values(module)) { + if (typeof exported !== "function") continue; + const metadata = getMetadata("muddle:orm:table", exported as object) as { name: string; fields: Map } | undefined; + if (!metadata) continue; + tables.push({ + name: metadata.name, + fields: [...metadata.fields.values()].map((field) => ({ + property: field.property, + column: field.column, + type: field.type, + nullable: field.nullable, + })), + }); + } + } + + return tables; +} + +async function listFiles(dir: string, suffix: string): Promise { + const entries = existsSync(dir) ? await readdir(dir, { withFileTypes: true }) : []; + const files: string[] = []; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await listFiles(path, suffix))); + else if (entry.name.endsWith(suffix)) files.push(path); + } + return files; +} + +/** Entry point of the generator. */ +if (import.meta.main) { + const root = process.argv[2]; + try { + const result = await generate({ root }); + for (const file of result.written) { + console.log(`muddle: generated ${relative(process.cwd(), file)}`); + } + } catch (error) { + console.error(`muddle: ${(error as Error).message}`); + process.exit(1); + } +} diff --git a/contracts/typegen/http/headers.ts b/contracts/typegen/http/headers.ts new file mode 100644 index 0000000..43654d1 --- /dev/null +++ b/contracts/typegen/http/headers.ts @@ -0,0 +1,48 @@ +export type HeaderValue = string; + +export type Headers = Record; + +export type KnownHeaderName = + | "accept" + | "accept-charset" + | "accept-encoding" + | "authorization" + | "cache-control" + | "content-length" + | "content-type" + | "cookie" + | "etag" + | "host" + | "if-none-match" + | "location" + | "origin" + | "referer" + | "set-cookie" + | "user-agent" + | "vary" + | "x-forwarded-for" + | "x-request-id"; + +/** Any header name; known names are suggested by the editor. */ +export type HeaderName = KnownHeaderName | (string & {}); + +export type ContentType = + | "text/html; charset=utf-8" + | "text/plain; charset=utf-8" + | "application/json; charset=utf-8" + | "application/x-www-form-urlencoded" + | "multipart/form-data" + | "application/octet-stream"; + +export const JSON_CONTENT_TYPE = "application/json; charset=utf-8" as const; +export const HTML_CONTENT_TYPE = "text/html; charset=utf-8" as const; +export const TEXT_CONTENT_TYPE = "text/plain; charset=utf-8" as const; +export const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded" as const; + +export function mediaType(contentType: string | undefined): string { + if (!contentType) return ""; + const semi = contentType.indexOf(";"); + return (semi === -1 ? contentType : contentType.slice(0, semi)) + .trim() + .toLowerCase(); +} diff --git a/contracts/typegen/http/index.ts b/contracts/typegen/http/index.ts new file mode 100644 index 0000000..157d3b6 --- /dev/null +++ b/contracts/typegen/http/index.ts @@ -0,0 +1,5 @@ +export * from "./headers.ts"; +export * from "./method.ts"; +export * from "./request.ts"; +export * from "./respcode.ts"; +export * from "./response.ts"; diff --git a/contracts/typegen/http/method.ts b/contracts/typegen/http/method.ts new file mode 100644 index 0000000..42244cc --- /dev/null +++ b/contracts/typegen/http/method.ts @@ -0,0 +1,33 @@ +export const HTTP_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS", +] as const; + +export type HttpMethod = (typeof HTTP_METHODS)[number]; + +/** Methods that are allowed to carry a request body. */ +export type BodyMethod = Extract< + HttpMethod, + "POST" | "PUT" | "PATCH" | "DELETE" +>; + +/** Methods that never mutate server state. */ +export type SafeMethod = Extract; + +export function isHttpMethod(value: string): value is HttpMethod { + return (HTTP_METHODS as readonly string[]).includes(value); +} + +/** Normalises user supplied verbs (`post`, `Post`) to the canonical form. */ +export function normalizeMethod(value: string): HttpMethod { + const upper = value.toUpperCase() as HttpMethod; + if (!isHttpMethod(upper)) { + throw new Error(`Unsupported HTTP method: ${value}`); + } + return upper; +} diff --git a/contracts/typegen/http/request.ts b/contracts/typegen/http/request.ts new file mode 100644 index 0000000..0a98272 --- /dev/null +++ b/contracts/typegen/http/request.ts @@ -0,0 +1,90 @@ +import type { Headers } from "./headers.ts"; +import type { StatusCode } from "./respcode.ts"; +import type { HttpMethod } from "./method.ts"; +import type { HttpResponse } from "./response.ts"; + +/** Extracts `:param` names from a route pattern. */ +export type ParamNames = + Path extends `${string}:${infer Param}/${infer Rest}` + ? ParamNames<`/${Rest}`> | Param + : Path extends `${string}:${infer Param}` + ? Param + : never; + +/** Query string keys of a route pattern (`/search?q=…` are not part of the path). */ +export type QueryNames = + Path extends `${string}?${infer Query}` ? Query : never; + +export type PathParams = { + [K in ParamNames]: string; +}; + +export type QueryParams = { + [K in QueryNames]: string; +}; + +/** Everything a handler may read from an incoming request. */ +export interface HttpRequest< + Path extends string = string, + Body = unknown, + ResponseBody = unknown, +> { + method: HttpMethod; + /** Full request URL including the query string. */ + url: string; + /** Path portion of the URL, without query string. */ + path: string; + /** Route parameters extracted from `Path`. */ + params: PathParams; + /** Parsed query string. */ + query: Record; + /** Parsed body (JSON / form / text / raw bytes). */ + body: Body; + /** Fields of a submitted form. */ + form: FormFields; + /** Lower-cased request headers. */ + headers: Headers; + /** Abort signal, wired to the platform request lifecycle. */ + signal: AbortSignal; + /** Typed response helper, the handler may return `response.json(...)` directly. */ + respond: ResponseBuilder; +} + +/** Typed response factory bound to a single request. */ +export interface ResponseBuilder { + html(body: string, status?: StatusCode): HttpResponse; + json(body: unknown, status?: StatusCode): HttpResponse; + text(body: string, status?: StatusCode): HttpResponse; + redirect(location: string, status?: StatusCode): HttpResponse; + status(code: StatusCode): ResponseBuilder; + header(name: string, value: string): ResponseBuilder; + send(body?: ResponseBody): HttpResponse; +} + +/** Fields of a submitted form. */ +export interface FormFields { + /** Value of a single field. */ + get(name: string): string | undefined; + /** All fields of the form. */ + all(): Record; + /** `true` when the form contains the field. */ + has(name: string): boolean; + /** All fields of the form as strings. */ + values(): Record; +} + +/** Mutable per-request scratch space, shared by guards, handlers and views. */ +export interface RequestLocals { + [key: string]: unknown; +} + +/** The object handed to a controller method. */ +export interface RequestContext< + Path extends string = string, + Body = unknown, + ResponseBody = unknown, +> extends HttpRequest { + locals: RequestLocals; + /** Platform specific request object (`BunRequest`, `Request`, …). */ + raw: unknown; +} diff --git a/contracts/typegen/http/respcode.ts b/contracts/typegen/http/respcode.ts new file mode 100644 index 0000000..e43e6a6 --- /dev/null +++ b/contracts/typegen/http/respcode.ts @@ -0,0 +1,39 @@ +export const STATUS_CODES = [ + 200, 201, 202, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404, 405, + 406, 409, 410, 415, 418, 422, 429, 500, 501, 502, 503, 504, +] as const; + +export type StatusCode = (typeof STATUS_CODES)[number]; + +export type InfoStatus = Extract; +export type RedirectStatus = Extract< + StatusCode, + 301 | 302 | 303 | 304 | 307 | 308 +>; +export type ClientErrorStatus = Extract< + StatusCode, + 400 | 401 | 403 | 404 | 405 | 406 | 409 | 410 | 415 | 418 | 422 | 429 +>; +export type ServerErrorStatus = Extract< + StatusCode, + 500 | 501 | 502 | 503 | 504 +>; + +export function isStatusCode(value: number): value is StatusCode { + return (STATUS_CODES as readonly number[]).includes(value); +} + +/** Default status code for a given request method. */ +export function defaultStatusFor(method: string): StatusCode { + switch (method.toUpperCase()) { + case "POST": + return 201; + case "PUT": + case "PATCH": + return 200; + case "DELETE": + return 204; + default: + return 200; + } +} diff --git a/contracts/typegen/http/response.ts b/contracts/typegen/http/response.ts new file mode 100644 index 0000000..2a8740a --- /dev/null +++ b/contracts/typegen/http/response.ts @@ -0,0 +1,52 @@ +import type { Headers } from "./headers.ts"; +import type { StatusCode } from "./respcode.ts"; + +/** Response body union: everything the platform adapters know how to send. */ +export type ResponseBody = + | string + | Uint8Array + | ArrayBuffer + | ReadableStream + | null + | undefined; + +/** + * Response contract. + * + * Handlers may return this shape directly, or return a plain value which is + * then wrapped by the router using the route's declared content type. + */ +export interface HttpResponse { + status: StatusCode; + headers: Headers; + body: Body; +} + +/** A route that renders a template instead of returning a value. */ +export interface ViewResponse extends HttpResponse { + /** Template path relative to the views directory, e.g. `pages/index`. */ + template: string; + /** Props handed to the template. */ + props: Record; +} + +/** + * A chunk of html that is rendered after the shell of the response. + * + * Deferred components never block the first byte, the response is streamed + * and the chunk is appended as soon as it is ready. + */ +export interface DeferredChunk { + /** Identifier of the chunk, used as the marker in the html. */ + id: string; + /** Content of the chunk. */ + content: Promise; +} + +/** Result of a view render. */ +export interface RenderedView { + /** Html of the shell. */ + html: string; + /** Chunks rendered after the shell. */ + deferred?: DeferredChunk[]; +} diff --git a/contracts/typegen/minify.ts b/contracts/typegen/minify.ts new file mode 100644 index 0000000..edff86d --- /dev/null +++ b/contracts/typegen/minify.ts @@ -0,0 +1,45 @@ +/** Flattens an intersection into one object type. */ +export type Minify = T extends (...args: never[]) => unknown + ? T + : { [K in keyof T]: T[K] } & {}; + +/** Alias of {@link Minify}, kept for readability in generated output. */ +export type Simplify = Minify; + +/** Makes every property of `T` readonly, recursively. */ +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly (infer U)[] + ? readonly DeepReadonly[] + : T extends object + ? { readonly [K in keyof T]: DeepReadonly } + : T; + +/** Removes `null` and `undefined` from a union. */ +export type Defined = Exclude; + +/** Marks the selected keys of `T` as optional. */ +export type WithOptional = Omit & + Partial>; + +/** Marks the selected keys of `T` as required. */ +export type WithRequired = Omit & + Required>; + +export function compactTypeSource(source: string): string { + const arrow = source.replace(/=>/g, "__ARROW__"); + return arrow + .replace(/\s+/g, " ") + .replace(/\s*([|&=<>?:,()[\]{}])\s*/g, "$1") + .replace(/;\s*$/, "") + .trim() + .replace(/__ARROW__/g, "=>"); +} + +export function indent(source: string, depth = 1): string { + const pad = " ".repeat(depth); + return source + .split("\n") + .map((line) => (line.length > 0 ? pad + line : line)) + .join("\n"); +} diff --git a/contracts/typegen/orm/field.ts b/contracts/typegen/orm/field.ts new file mode 100644 index 0000000..1cc46b0 --- /dev/null +++ b/contracts/typegen/orm/field.ts @@ -0,0 +1,80 @@ +/** Storage types, mapped per dialect by the adapters. */ +export const FIELD_TYPES = [ + "text", + "int", + "bigint", + "real", + "bool", + "blob", + "json", + "uuid", + "timestamp", + "date", +] as const; + +export type FieldType = (typeof FIELD_TYPES)[number]; + +export type ReferentialAction = + | "CASCADE" + | "SET NULL" + | "RESTRICT" + | "NO ACTION" + | "SET DEFAULT"; + +export interface ReferenceOptions { + /** Target table. */ + table: string; + /** Target column, defaults to the primary key of the target table. */ + column?: string; + onDelete?: ReferentialAction; + onUpdate?: ReferentialAction; +} + +/** Options accepted by the `@Field()` decorator. */ +export interface FieldOptions { + /** Storage type of the column. */ + type: FieldType; + /** Column name, defaults to the snake_cased property name. */ + column?: string; + /** Marks the column as part of the primary key. */ + primary?: boolean; + /** Adds a unique constraint. */ + unique?: boolean; + /** Allows `NULL` values. */ + nullable?: boolean; + /** Column default, emitted into the `CREATE TABLE` statement. */ + default?: unknown; + /** Foreign key reference. */ + references?: ReferenceOptions; + /** + * Transformation applied to the value right before it is written to the + * database (after the write, before the commit). + */ + encode?: (value: any) => unknown; + /** + * Transformation applied to the raw column value while the row is hydrated, + * i.e. before application code can access the property. + */ + decode?: (value: any) => Value; +} + +/** Runtime description of a single field, collected by the `@Field()` decorator. */ +export interface FieldDescriptor { + /** Property name on the model class. */ + property: string; + /** Column name in the database. */ + column: string; + options: FieldOptions; +} + +/** Flattened column description used by the schema builder and the repository. */ +export interface ColumnMapping { + property: string; + column: string; + type: FieldType; + nullable: boolean; + primary: boolean; + unique: boolean; + default: unknown; + references?: ReferenceOptions; +} diff --git a/contracts/typegen/orm/index.ts b/contracts/typegen/orm/index.ts new file mode 100644 index 0000000..8a0dd3c --- /dev/null +++ b/contracts/typegen/orm/index.ts @@ -0,0 +1,3 @@ +export * from "./field.ts"; +export * from "./repository.ts"; +export * from "./table.ts"; diff --git a/contracts/typegen/orm/repository.ts b/contracts/typegen/orm/repository.ts new file mode 100644 index 0000000..675125e --- /dev/null +++ b/contracts/typegen/orm/repository.ts @@ -0,0 +1,108 @@ +/** Comparison operators supported by the query builder. */ +export const WHERE_OPERATORS = [ + "=", + "!=", + "<", + "<=", + ">", + ">=", + "like", + "ilike", + "in", + "not in", + "is null", + "is not null", +] as const; + +export type WhereOperator = (typeof WHERE_OPERATORS)[number]; + +export interface WhereCondition { + column: string; + operator: WhereOperator; + value?: unknown; +} + +/** Where input accepted by the fluent builder. */ +export type WhereInput = + | Partial, unknown>> + | WhereCondition[]; + +export type OrderDirection = "asc" | "desc"; + +export interface OrderBy { + column: string; + direction: OrderDirection; +} + +/** Column names of a row, used to keep the builder type safe. */ +export type ColumnName = Extract; + +/** The value handed to `create()`. */ +export type CreateData = Partial< + Record, unknown> +>; + +export interface Repository { + /** Restricts the selected columns. */ + select(...columns: ColumnName[]): Repository; + /** Adds a condition, implicitly combined with `AND`. */ + where(column: ColumnName, value: unknown): Repository; + /** Adds a condition with an explicit operator. */ + where( + column: ColumnName, + operator: WhereOperator, + value?: unknown, + ): Repository; + /** Adds a raw condition list. */ + where(conditions: WhereCondition[]): Repository; + /** Adds a group of conditions combined with `AND`. */ + and(conditions: WhereCondition[]): Repository; + /** Adds a group of conditions combined with `OR`. */ + or(conditions: WhereCondition[]): Repository; + /** Orders the result set. */ + orderBy(column: ColumnName, direction?: OrderDirection): Repository; + /** Limits the result set. */ + limit(count: number): Repository; + /** Skips rows. */ + offset(count: number): Repository; + /** Runs a raw statement and returns the rows. */ + query>( + sql: string, + params?: readonly unknown[], + ): Promise; + /** Executes the query and hydrates every row. */ + all(): Promise; + /** Executes the query and hydrates the first row. */ + first(): Promise; + /** Counts the rows matching the current conditions. */ + count(): Promise; + /** Inserts a row, encoding every value before it is committed. */ + create(data: Partial): Promise; + /** Updates the matching rows, encoding every value before the commit. */ + update(data: Partial): Promise; + /** Deletes the matching rows. */ + delete(): Promise; +} + +/** Dialect neutral SQL surface implemented by the adapters. */ +export interface SqlDriver { + readonly dialect: "sqlite" | "postgres"; + /** Placeholder for the n-th parameter (1 based). */ + placeholder(index: number): string; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning: boolean; + /** Runs a statement and returns all rows. */ + query>( + sql: string, + params?: readonly unknown[], + ): Promise; + /** Runs a statement and returns the number of affected rows. */ + execute( + sql: string, + params?: readonly unknown[], + ): Promise<{ changes: number }>; + /** Runs `fn` inside a transaction. */ + transaction(fn: (driver: SqlDriver) => Promise): Promise; + /** Releases the underlying resources. */ + close(): Promise; +} diff --git a/contracts/typegen/orm/table.ts b/contracts/typegen/orm/table.ts new file mode 100644 index 0000000..7f0a84a --- /dev/null +++ b/contracts/typegen/orm/table.ts @@ -0,0 +1,31 @@ +import type { ColumnMapping } from "./field.ts"; + +/** Options accepted by the `@Table()` decorator. */ +export interface TableOptions { + /** Optional schema/namespace, e.g. `public`. */ + schema?: string; + /** Primary key columns, defaults to the columns flagged with `primary: true`. */ + primaryKey?: string | string[]; + /** Adds `created_at` / `updated_at` columns. */ + timestamps?: boolean; + /** Emit `IF NOT EXISTS` when creating the table. */ + ifNotExists?: boolean; + /** Additional `CREATE TABLE` suffix, e.g. `WITHOUT ROWID`. */ + suffix?: string; +} + +/** Runtime description of a table, collected by the `@Table()` decorator. */ +export interface TableDescriptor { + /** Table name. */ + name: string; + options: TableOptions; + /** Columns in declaration order. */ + columns: ColumnMapping[]; + /** Property names that are hydrated but never persisted. */ + hydrated: string[]; +} + +/** A model class decorated with `@Table()`. */ +export type TableClass = { + new (...args: any[]): Row; +} & { readonly __table?: TableDescriptor }; diff --git a/contracts/typegen/orm/tables.ts b/contracts/typegen/orm/tables.ts new file mode 100644 index 0000000..80e1a7c --- /dev/null +++ b/contracts/typegen/orm/tables.ts @@ -0,0 +1,4 @@ +/** Fallback table contracts. Project contracts are generated under `_muddle/contracts`. */ +export interface TableRows {} +export type TableName = keyof TableRows; +export type TableRowOf = TableRows[T]; diff --git a/contracts/typegen/views.ts b/contracts/typegen/views.ts new file mode 100644 index 0000000..b255d6c --- /dev/null +++ b/contracts/typegen/views.ts @@ -0,0 +1,6 @@ +import type { Minify } from "./minify.ts"; + +/** Fallback view contracts. Project contracts are generated under `_muddle/contracts`. */ +export interface ViewProps { [path: string]: Record } +export type ViewPath = string; +export type ViewPropsOf

= Minify; diff --git a/core/app.ts b/core/app.ts new file mode 100644 index 0000000..645c308 --- /dev/null +++ b/core/app.ts @@ -0,0 +1,201 @@ +import { ModuleRegistry } from "./modules.ts"; +import { Router, type ViewDescriptor } from "./router.ts"; +import { createRequestContext, parseRequestBody } from "./http.ts"; +import { toFormFields } from "./form.ts"; +import type { Type } from "./container.ts"; +import type { HttpResponse } from "../contracts/typegen/http/index.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import type { RenderedView } from "../contracts/typegen/http/response.ts"; +import type { + Integration, + IntegrationContext, + Mounts, + ResolvedConfig, + BuildAsset, +} from "../contracts/index.ts"; +import type { ServerBuildContext } from "./context.ts"; +import type { RoutePipeline } from "./pipeline.ts"; + +/** Renders the templates of the application. */ +export interface ViewRenderer { + renderDescriptor( + descriptor: ViewDescriptor, + context: RequestContext, + ): Promise | string | RenderedView; + /** Registry of named components, mount targets of the integrations. */ + components?: Map; +} + +export interface AppOptions { + /** Root modules of the application. */ + modules?: Type[]; + /** Renderer used for the templates of the routes. */ + views?: ViewRenderer; + /** Resolved configuration of the project. */ + config?: ResolvedConfig; + /** Integrations of the project. */ + integrations?: Integration[]; + server?: ServerBuildContext; + pipeline?: RoutePipeline; +} + +export class App { + readonly registry: ModuleRegistry; + readonly router: Router; + private readonly config?: ResolvedConfig; + private readonly integrations: Integration[]; + private readonly views: ViewRenderer | undefined; + private readonly server; + + constructor(options: AppOptions = {}) { + this.config = options.config; + this.integrations = options.integrations ?? []; + this.views = options.views; + this.server = options.server?.build() ?? { headers: {}, locals: {} }; + this.registry = new ModuleRegistry(); + const views = options.views; + this.router = new Router({ + pipeline: options.pipeline, + renderView: views + ? (descriptor, context) => views.renderDescriptor(descriptor, context) + : undefined, + }); + + for (const moduleType of options.modules ?? []) { + this.registry.register(moduleType); + } + } + + get resolvedConfig(): ResolvedConfig | undefined { + return this.config; + } + + async transformAsset(asset: BuildAsset): Promise { + for (const integration of this.integrations) if (integration.transform) asset = await integration.transform(asset); + return asset; + } + + async analyzePage(page: { route: string; html: string }): Promise> { + const reports: Record = {}; + for (const integration of this.integrations) if (integration.analyze) reports[integration.name] = await integration.analyze(page); + return reports; + } + + /** Creates the application and awaits the asynchronous providers. */ + static async create(options: AppOptions = {}): Promise { + const app = new App(options); + await app.init(); + return app; + } + + /** Runs the integrations and instantiates the controllers. */ + async init(): Promise { + const integrationContext: IntegrationContext = { + config: this.config ?? ({} as ResolvedConfig), + route: (method, path, handler) => this.router.raw(method, path, handler), + loader: (name, loader) => { + if (this.config) this.config.loaders[name] = loader; + }, + }; + for (const integration of this.integrations) { + await integration.setup?.(integrationContext); + for (const mount of integration.mounts ?? []) { + if (mount.views && this.views) { + await mount.views(this.views as unknown as Parameters>[0]); + } + if (mount.routes) { + await mount.routes((method, path, handler) => + this.router.raw(method, path, (_request, _context) => handler(_request)), + ); + } + if (mount.styles) { + for (const [path, value] of Object.entries(mount.styles)) { + this.router.raw("GET", path, async () => + new Response(typeof value === "function" ? await value() : value, { + headers: { "content-type": "text/css; charset=utf-8" }, + }), + ); + } + } + } + } + + await this.registry.bootstrapAsync(); + for (const { context, controller } of this.registry.controllers()) { + const instance = await context.container.resolveAsync(controller); + this.router.register(controller, instance); + } + + for (const integration of this.integrations) { + await integration.ready?.(); + } + } + + /** Adds a route that is not bound to a controller. */ + route( + method: string, + path: string, + handler: ( + request: Request, + context: RequestContext, + ) => Promise | Response, + ): void { + this.router.raw(method, path, handler); + } + + /** Handles a fetch request and returns the platform response. */ + async fetch(request: Request): Promise { + const context = createRequestContext(request); + Object.assign(context.locals, this.server.locals); + const response = await this.handle(request, context); + const fetchResponse = toFetchResponse(response); + for (const [name, value] of Object.entries(this.server.headers)) fetchResponse.headers.set(name, value); + return fetchResponse; + } + + /** Handles a request with a pre-built context. */ + async handle( + request: Request, + context: RequestContext, + ): Promise { + for (const integration of this.integrations) await integration.request?.(context); + context.body = await parseRequestBody(request); + context.form = toFormFields( + context.body && + typeof context.body === "object" && + !Array.isArray(context.body) + ? context.body + : undefined, + ); + return this.router.handle(context); + } +} + +/** Converts the framework response into a fetch `Response`. */ +export function toFetchResponse(response: HttpResponse): Response { + const headers = new Headers(); + for (const [name, value] of Object.entries(response.headers ?? {})) { + headers.set(name, value); + } + + const status = response.status; + const nullBody = status === 204 || status === 304; + const body = response.body; + + if (nullBody || body === undefined || body === null) { + return new Response(null, { status, headers }); + } + if (typeof body === "string") { + return new Response(body, { status, headers }); + } + if (body instanceof Uint8Array) { + return new Response(body, { status, headers }); + } + if (body instanceof ArrayBuffer) { + return new Response(body, { status, headers }); + } + if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) { + return new Response(body, { status, headers }); + } + return new Response(JSON.stringify(body), { status, headers }); +} diff --git a/core/backport.ts b/core/backport.ts new file mode 100644 index 0000000..ce490d6 --- /dev/null +++ b/core/backport.ts @@ -0,0 +1,497 @@ +import { cp, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { dirname, extname, join, relative, resolve } from "node:path"; +import { transform } from "lightningcss"; +import ts from "typescript"; + +export type BackportSource = "astro" | "zola"; + +export interface BackportResult { + source: BackportSource; + output: string; + files: string[]; +} + +const ASSET_EXTENSIONS = new Set([ + ".avif", + ".gif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".webp", + ".woff", + ".woff2", + ".ttf", + ".pdf", + ".txt", +]); + +export function detectBackportSource(root: string): BackportSource { + if ( + existsSync(join(root, "config.toml")) || + existsSync(join(root, "content")) && existsSync(join(root, "templates")) + ) return "zola"; + if ( + existsSync(join(root, "astro.config.mjs")) || + existsSync(join(root, "astro.config.ts")) || + existsSync(join(root, "src", "pages")) + ) return "astro"; + throw new Error(`Could not identify a Zola or Astro project in ${root}`); +} + +export async function backportProject( + input: string, + output: string, + source = detectBackportSource(input), +): Promise { + const inputRoot = resolve(input); + const outputRoot = resolve(output); + if (inputRoot === outputRoot) { + throw new Error( + "The Muddle destination must be different from the source directory", + ); + } + const files: string[] = []; + const write = async (path: string, content: string) => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, content); + files.push(relative(outputRoot, path).replaceAll("\\", "/")); + }; + + const contentRoot = join(inputRoot, source === "astro" ? "src/content" : "content"); + await writeProjectShell(outputRoot, write, await collectionNames(contentRoot), source === "zola" ? await zolaSiteConfig(join(inputRoot, "config.toml")) : {}); + const mappings = source === "astro" + ? [ + { from: "src/pages", to: "modules/app/views" }, + { from: "src/layouts", to: "modules/app/views/layouts" }, + { from: "src/components", to: "modules/app/views/components" }, + { from: "src/content", to: "content" }, + { from: "public", to: "public" }, + { from: "src/assets", to: "public/assets" }, + { from: "src/styles", to: "styles" }, + ] + : [ + { from: "templates", to: "modules/app/views" }, + { from: "content", to: "content" }, + { from: "static", to: "public" }, + { from: "sass", to: "styles" }, + ]; + for (const mapping of mappings) { + const folder = join(inputRoot, mapping.from); + if (existsSync(folder)) { + await convertFolder(folder, join(outputRoot, mapping.to), write, source, folder); + } + } + return { source, output: outputRoot, files }; +} + +async function collectionNames(root: string): Promise { + if (!existsSync(root)) return []; + const names: string[] = []; + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isDirectory()) names.push(entry.name.replace(/[^\w-]/g, "-")); + } + return names.sort(); +} + +async function zolaSiteConfig(path: string): Promise> { + if (!existsSync(path)) return {}; + const source = await readFile(path, "utf8"); + const values: Record = {}; + for (const key of ["title", "base_url", "description", "default_language"]) { + const match = new RegExp(`^\\s*${key}\\s*=\\s*["']([^"']*)["']\\s*$`, "m").exec(source); + if (match?.[1] !== undefined) values[key] = match[1]; + } + return values; +} + +async function writeProjectShell( + root: string, + write: (path: string, content: string) => Promise, + collections: string[], + site: Record, +): Promise { + const collectionConfig = collections.length + ? `,\n collections: [${collections.map((name) => `{ name: ${JSON.stringify(name)}, dir: ${JSON.stringify(`content/${name}`)}, loader: "markdown" }`).join(", ")}]` + : ""; + await write( + join(root, "package.json"), + `{\n "name": "muddle-backport",\n "private": true,\n "type": "module",\n "scripts": { "dev": "muddle dev", "build": "muddle build", "typegen": "muddle typegen" },\n "dependencies": { "muddle": "latest" }\n}\n`, + ); + await write( + join(root, "ct.config.ts"), + `import { defineConfig } from "muddle/contracts";\nimport { markdownIntegration, stcssIntegration, sassIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n root: import.meta.dir,\n views: "modules/app/views",\n viewRoots: ["modules/app/views"],\n integrations: [markdownIntegration(), stcssIntegration(), sassIntegration()]${collectionConfig},\n prerender: { routes: ["/"], outDir: "dist" },\n});\n`, + ); + await write( + join(root, "index.ts"), + `import { join } from "node:path";\nimport { App } from "muddle/core/app";\nimport { prerender } from "muddle/adapters/static";\nimport { createViews } from "muddle/views/api";\nimport { resolved } from "./config.ts";\nimport { AppModule } from "./modules/app/app.module.ts";\n\nexport const app = await App.create({ modules: [AppModule], config: resolved, integrations: resolved.integrations, views: createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components }) });\nexport async function build() {\n if (resolved.prerender) await prerender(app, { ...resolved.prerender, outDir: join(resolved.root, resolved.prerender.outDir ?? "dist") });\n}\n`, + ); + await write( + join(root, "config.ts"), + `import { resolveConfig } from "muddle/core/config";\nimport config from "./ct.config.ts";\nexport const resolved = resolveConfig(config);\n`, + ); + await write( + join(root, "modules/app/app.module.ts"), + `import { Module } from "muddle/decorators";\nimport { AppController } from "./app.controller.ts";\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`, + ); + await write( + join(root, "modules/app/site.config.ts"), + `/** Static values imported from the source site's configuration. */\nexport const site = ${JSON.stringify({ title: site.title ?? "Muddle backport", baseUrl: site.base_url ?? "", description: site.description ?? "", language: site.default_language ?? "en" }, null, 2)} as const;\n`, + ); + await write( + join(root, "modules/app/app.controller.ts"), + `import { Controller, View, ViewRoot } from "muddle/decorators";\nimport { site } from "./site.config.ts";\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n @View("index.spug")\n index() { return { title: site.title, site }; }\n}\n`, + ); +} + +async function convertFolder( + source: string, + destination: string, + write: (path: string, content: string) => Promise, + kind: BackportSource, + sourceRoot: string, +): Promise { + for (const entry of await readdir(source, { withFileTypes: true })) { + const from = join(source, entry.name); + const to = join(destination, entry.name); + if (entry.isDirectory()) { + await convertFolder(from, to, write, kind, sourceRoot); + continue; + } + const extension = extname(entry.name).toLowerCase(); + if ( + extension === ".astro" || extension === ".html" || + extension === ".htm" || extension === ".tera" + ) { + await write( + to.replace(/\.(astro|html?|tera)$/i, ".spug"), + kind === "astro" + ? astroToSpug(await readFile(from, "utf8"), from, sourceRoot) + : zolaToSpug(await readFile(from, "utf8")), + ); + } else if (extension === ".tsx" || extension === ".jsx") { + await write(to.replace(/\.(tsx|jsx)$/i, ".spug"), reactToSpug(await readFile(from, "utf8"), from)); + } else if (extension === ".css") { + await write( + to.replace(/\.css$/i, ".stcss"), + cssToStcss(await readFile(from, "utf8"), from), + ); + } else if (extension === ".scss" || extension === ".sass") { + await write( + to.replace(/\.(scss|sass)$/i, ".scss"), + await readFile(from, "utf8"), + ); + } else if ( + extension === ".md" || extension === ".markdown" || + ASSET_EXTENSIONS.has(extension) + ) { + await mkdir(dirname(to), { recursive: true }); + await cp(from, to); + } + } +} + +export function reactToSpug(source: string, filename = "component.tsx"): string { + const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const analysis = analyzeReactComponent(source, filename); + const imports = new Set(); + let root: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment | undefined; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings)) { + for (const element of node.importClause.namedBindings.elements) imports.add(element.name.text); + } + if (ts.isImportDeclaration(node) && node.importClause?.name) imports.add(node.importClause.name.text); + if (ts.isReturnStatement(node) && node.expression && !root) { + const expression = unwrapJsxExpression(node.expression); + if (isJsxNode(expression)) root = expression; + } + if (ts.isArrowFunction(node) && !root && ts.isExpression(node.body)) { + const expression = unwrapJsxExpression(node.body); + if (isJsxNode(expression)) root = expression; + } + ts.forEachChild(node, visit); + }; + visit(file); + if (!root) throw new Error(`No JSX return value found in ${filename}`); + const aot = analysis.state.map((state) => ` const ${state.name} = ${state.initial};`); + const script = aot.length || analysis.hooks.length + ? `javascript!\n // React hooks lowered during static component composition: ${analysis.hooks.join(", ") || "none"}\n${aot.join("\n")}\nend\n` + : ""; + return `${script}${jsxToSpug(root, imports).trim()}\n`; +} + +export interface ReactHookAnalysis { + hooks: string[]; + state: { name: string; initial: string }[]; +} + +/** Finds React hooks and extracts AOT-safe initial state from a component. */ +export function analyzeReactComponent(source: string, filename = "component.tsx"): ReactHookAnalysis { + const file = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + const hooks = new Set(); + const state: { name: string; initial: string }[] = []; + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = ts.isIdentifier(node.expression) ? node.expression.text + : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : ""; + if (/^use[A-Z]/.test(name)) hooks.add(name); + if (name === "useState" && ts.isVariableDeclaration(node.parent) && ts.isArrayBindingPattern(node.parent.name)) { + const binding = node.parent.name.elements[0]; + if (binding && ts.isBindingElement(binding) && ts.isIdentifier(binding.name)) { + state.push({ name: binding.name.text, initial: node.arguments[0]?.getText() ?? "undefined" }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(file); + return { hooks: [...hooks].sort(), state }; +} + +function unwrapJsxExpression(node: ts.Expression): ts.Expression { + return ts.isParenthesizedExpression(node) ? unwrapJsxExpression(node.expression) : node; +} + +function isJsxNode(node: ts.Node): node is ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment { + return ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node); +} + +function jsxToSpug(node: ts.JsxChild | ts.JsxElement | ts.JsxSelfClosingElement | ts.JsxFragment, imports: Set, depth = 0): string { + const pad = " ".repeat(depth); + if (ts.isJsxText(node)) { + const text = node.getText().replace(/\s+/g, " ").trim(); + return text ? `${pad}| ${text}` : ""; + } + if (ts.isJsxExpression(node)) { + if (!node.expression) return ""; + return `${pad}= ${node.expression.getText()}`; + } + if (ts.isJsxFragment(node)) return node.children.map((child) => jsxToSpug(child, imports, depth)).filter(Boolean).join("\n"); + const opening = ts.isJsxElement(node) ? node.openingElement : node; + const rawName = opening.tagName.getText(); + const name = rawName === "Comp" ? "button" : rawName; + const component = ( /^[A-Z]/.test(name) || imports.has(name)) && rawName !== "Comp"; + const attributes = jsxAttributes(opening.attributes.properties, component); + const line = component ? `${pad}component ${kebab(name)}` : `${pad}${name}${attributes.inline}`; + const props = component && attributes.props.length ? `\n${attributes.props.map((property) => `${pad} ${property}`).join("\n")}` : ""; + if (ts.isJsxSelfClosingElement(node)) { + return depth === 0 ? `${line}${props}\n${pad} if content\n${pad} != await content()` : `${line}${props}`; + } + const children = node.children.map((child) => jsxToSpug(child, imports, depth + 1)).filter(Boolean).join("\n"); + return [line + props, children].filter(Boolean).join("\n"); +} + +function jsxAttributes(properties: ts.NodeArray, component = false): { inline: string; props: string[] } { + const inline: string[] = []; + const props: string[] = []; + for (const property of properties) { + if (ts.isJsxSpreadAttribute(property)) continue; + const name = property.name.getText(); + if (name === "ref") continue; + const initializer = property.initializer; + if (!initializer) { inline.push(`${name}=true`); continue; } + const value = ts.isStringLiteral(initializer) ? JSON.stringify(initializer.text) + : ts.isJsxExpression(initializer) && initializer.expression ? `(${initializer.expression.getText()})` + : JSON.stringify(initializer.getText()); + const normalized = name === "className" ? "class" : name; + const classValue = normalized === "class" && ts.isJsxExpression(initializer) && initializer.expression + ? staticClassValue(initializer.expression) ?? JSON.stringify("muddle-react") + : value; + if (component) props.push(`${normalized}: ${classValue}`); + else inline.push(`${normalized}=${classValue}`); + } + return { inline: inline.length ? `(${inline.join(", ")})` : "", props }; +} + +function staticClassValue(expression: ts.Expression): string | undefined { + if (ts.isStringLiteral(expression)) return JSON.stringify(expression.text); + if (!ts.isCallExpression(expression)) return undefined; + const classes = expression.arguments.flatMap((argument) => ts.isStringLiteral(argument) ? [argument.text] : []); + return classes.length ? JSON.stringify(classes.join(" ")) : undefined; +} + +function kebab(value: string): string { + return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase(); +} + +export function cssToStcss(source: string, filename = "style.css"): string { + const css = transform({ + filename, + code: Buffer.from(source), + minify: false, + }).code.toString(); + let depth = 0; + const lines: string[] = []; + for (const token of css.replace(/\r/g, "").split(/([{};])/)) { + const value = token.trim(); + if (!value) continue; + if (value === "}") { + depth = Math.max(0, depth - 1); + lines.push(`${" ".repeat(depth)}}`); + continue; + } + if (value === "{") { + const previous = lines.pop() ?? ""; + lines.push(`${previous} {`); + depth += 1; + continue; + } + if (value === ";") { + const previous = lines.pop() ?? ""; + lines.push(`${previous};`); + continue; + } + lines.push(`${" ".repeat(depth)}${value}`); + } + return `${lines.join("\n").trim()}\n`; +} + +export function htmlToSpug(source: string): string { + const body = source.replace(/^---[\s\S]*?---\s*/m, "").replace( + /^---[\s\S]*?---\s*/m, + "", + ).replace(//g, ""); + const tokens = body.match(/<[^>]+>|[^<]+/g) ?? []; + const lines: string[] = []; + let depth = 0; + for (const token of tokens) { + if (/^<\//.test(token)) { + depth = Math.max(0, depth - 1); + continue; + } + if (/^$/.exec(token); + if (!match) continue; + const tag = match[1]!; + const attrs = attributesToSpug(match[2] ?? ""); + lines.push(`${" ".repeat(depth)}${tag}${attrs}`); + if ( + !/\/$/.test(token) && + !new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "source", + "track", + "wbr", + ]).has(tag) + ) depth += 1; + } else { + const text = token.replace(/\s+/g, " ").trim(); + if (text) { + lines.push( + `${" ".repeat(depth)}| ${ + text.replace(/\{\{\s*(.*?)\s*\}\}/g, "#{ $1 }").replace(/\{([A-Za-z_$][\w$]*(?:\.[\w$]+)*)\}/g, "#{ $1 }") + }`, + ); + } + } + } + return `${lines.join("\n")}\n`; +} + +/** Converts an Astro source file without evaluating its frontmatter. */ +export function astroToSpug(source: string, file = "page.astro", pagesRoot = ""): string { + const frontmatter = /^---\s*\n([\s\S]*?)\n---\s*\n?/.exec(source)?.[1] ?? ""; + let body = source.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, ""); + const imports = new Map(); + for (const entry of frontmatter.matchAll(/import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["'];?/g)) { + imports.set(entry[1]!, entry[2]!); + } + const layout = /^\s*<([A-Z][\w$]*)\b([^>]*)>([\s\S]*)<\/\1>\s*$/.exec(body); + let prefix = ""; + if (layout && imports.has(layout[1]!)) { + const imported = imports.get(layout[1]!)!; + if (/(?:^|\/)layouts\//.test(imported)) { + const absoluteLayout = resolve(dirname(file), imported); + const pageRelative = relative(pagesRoot, file); + const layoutRelative = relative(dirname(pagesRoot), absoluteLayout).replace(/\.astro$/, ".spug"); + const target = relative(dirname(pageRelative), layoutRelative).replaceAll("\\", "/"); + prefix = `extends ${target}\n\nblock content\n`; + body = layout[3]!; + } + } + body = body + .replace(//g, "") + .replace(//g, ""); + const converted = htmlToSpug(body) + .replace(/^(\s*)muddle-slot\(data-name="([\w-]+)"\)\s*$/gm, "$1block $2") + .replace(/^(\s*)muddle-slot\s*$/gm, "$1block content"); + const componentized = componentCalls(converted, imports); + return prefix ? `${prefix}${indent(componentized)}` : componentized; +} + +/** Converts Tera inheritance, blocks, includes and expressions into SPUG. */ +export function zolaToSpug(source: string): string { + let result = source + .replace(/\{#([\s\S]*?)#\}/g, "") + .replace(/\{%\s*extends\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `extends ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`) + .replace(/\{%\s*include\s+["']([^"']+)["']\s*%\}/g, (_all, path: string) => `include ./${path.replace(/\.html?$|\.tera$/i, ".spug")}`) + .replace(/\{%\s*block\s+([\w-]+)\s*%\}/g, "block $1") + .replace(/\{%\s*endblock\s*%\}/g, "") + .replace(/\{%\s*if\s+(.+?)\s*%\}/g, "if $1") + .replace(/\{%\s*endif\s*%\}/g, "") + .replace(/\{%\s*for\s+([\w$]+)\s+in\s+(.+?)\s*%\}/g, "each $1 in $2") + .replace(/\{%\s*endfor\s*%\}/g, "") + .replace(/\{\{\s*([^}]+?)\s*\}\}/g, "#{ $1 }"); + result = htmlToSpug(result); + return result.replace(/\| (extends|include|block|if|each) (.+)$/gm, "$1 $2"); +} + +function componentCalls(source: string, imports: Map): string { + const output: string[] = []; + for (const line of source.split("\n")) { + const match = /^(\s*)([A-Z][\w$]*)(.*)$/.exec(line); + if (!match || !imports.has(match[2]!)) { output.push(line); continue; } + const imported = imports.get(match[2]!)!; + const name = imported.replace(/^.*\//, "").replace(/\.astro$/, "").replace(/[A-Z]/g, (letter, index) => `${index ? "-" : ""}${letter.toLowerCase()}`); + const attributeText = /\((.*)\)$/.exec(match[3]!)?.[1]; + output.push(`${match[1]}component ${name}`); + for (const pair of attributeText?.split(/\s*,\s*/) ?? []) { + const [key = "", raw = "true"] = pair.split(/=(.+)/); + const expression = /^\((.*)\)$/.exec(raw.trim())?.[1] ?? raw.trim().replace(/^\{\s*|\s*\}$/g, ""); + const dynamic = raw.trim().startsWith("{") || raw.trim().startsWith("("); + output.push(`${match[1]} ${key.trim()}: ${dynamic ? expression : expression === "true" ? "true" : JSON.stringify(expression)}`); + } + } + return `${output.filter(Boolean).join("\n").trim()}\n`; +} + +function indent(source: string): string { + return source.split("\n").filter(Boolean).map((line) => ` ${line}`).join("\n") + "\n"; +} + +function attributesToSpug(raw: string): string { + const classes: string[] = []; + const attributes: string[] = []; + for ( + const match of raw.matchAll( + /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g, + ) + ) { + const name = match[1]!; + const value = match[2] ?? match[3] ?? match[4]; + if (name === "class" && value) { + classes.push(...value.split(/\s+/).filter(Boolean)); + continue; + } + if (name === "id" && value && !value.startsWith("{")) { + attributes.push(`id=${JSON.stringify(value)}`); + continue; + } + if (value === undefined) attributes.push(`${name}=true`); + else if (/^\{[\s\S]*\}$/.test(value)) attributes.push(`${name}=(${value.slice(1, -1).trim()})`); + else attributes.push(`${name}=${JSON.stringify(value)}`); + } + return `${classes.map((value) => `.${value}`).join("")}${attributes.length ? `(${attributes.join(", ")})` : ""}`; +} diff --git a/core/cache.ts b/core/cache.ts new file mode 100644 index 0000000..362cd08 --- /dev/null +++ b/core/cache.ts @@ -0,0 +1,55 @@ +import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +export interface Cache { + get(key: string): Promise; + put(key: string, value: Uint8Array, options?: { ttl?: number }): Promise; + delete(key: string): Promise; +} + +export interface CloudflareCacheLike { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; + delete(request: Request): Promise; +} + +function cacheKey(key: string): string { + return encodeURIComponent(key); +} + +export function filesystemCache(dir: string): Cache { + return { + async get(key) { + const path = join(dir, cacheKey(key)); + if (!existsSync(path)) return undefined; + return new Uint8Array(await readFile(path)); + }, + async put(key, value) { + await mkdir(dir, { recursive: true }); + const path = join(dir, cacheKey(key)); + const staged = `${path}.next`; + await writeFile(staged, value); + await rename(staged, path); + }, + async delete(key) { + const path = join(dir, cacheKey(key)); + if (existsSync(path)) await rename(path, `${path}.deleted`); + }, + }; +} + +export function cloudflareCache(cache: CloudflareCacheLike, origin = "https://muddle.cache"): Cache { + const request = (key: string) => new Request(new URL(`/${encodeURIComponent(key)}`, origin).toString()); + return { + async get(key) { + const response = await cache.match(request(key)); + return response ? new Uint8Array(await response.arrayBuffer()) : undefined; + }, + async put(key, value, options) { + const headers = options?.ttl ? { "cache-control": `max-age=${options.ttl}` } : undefined; + await cache.put(request(key), new Response(value, { headers })); + }, + async delete(key) { await cache.delete(request(key)); }, + }; +} diff --git a/core/config.ts b/core/config.ts new file mode 100644 index 0000000..164807e --- /dev/null +++ b/core/config.ts @@ -0,0 +1,36 @@ +import { join, resolve } from "node:path"; +import type { MuddleConfig, ResolvedConfig } from "../contracts/index.ts"; + +/** Applies the defaults of a project configuration. */ +export function resolveConfig( + config: MuddleConfig = {}, + root: string = process.cwd(), +): ResolvedConfig { + const resolvedRoot = resolve(config.root ?? root); + const views = config.views ?? "views"; + return { + ...config, + root: resolvedRoot, + views: resolve(resolvedRoot, views), + components: resolve( + resolvedRoot, + config.components ?? join(views, "components"), + ), + content: resolve(resolvedRoot, config.content ?? "content"), + styles: resolve(resolvedRoot, config.styles ?? "styles"), + publicDir: resolve(resolvedRoot, config.publicDir ?? "public"), + viewRoots: (config.viewRoots ?? [views]).map((entry) => + resolve(resolvedRoot, entry), + ), + loaders: config.loaders ?? {}, + collections: config.collections ?? [], + integrations: config.integrations ?? [], + }; +} + +/** Reads the configuration of a project from its `ct.config.ts`. */ +export async function loadConfig(root: string): Promise { + const configPath = join(root, "ct.config.ts"); + const module = (await import(configPath)) as { default?: MuddleConfig }; + return module.default ?? {}; +} diff --git a/core/container.ts b/core/container.ts new file mode 100644 index 0000000..3624c62 --- /dev/null +++ b/core/container.ts @@ -0,0 +1,307 @@ +import { UnknownProviderError } from "./errors.ts"; +import { getLatestMetadata, getMetadata } from "./metadata.ts"; + +export type Type = new (...args: never[]) => T; +export type AbstractType = abstract new (...args: never[]) => T; +export type Token = Type | AbstractType | symbol | string; + +export type ProviderScope = "singleton" | "request" | "transient"; + +export interface ProviderDefinition { + /** Token the provider is registered under, defaults to `useClass`. */ + provide?: Token; + /** Static value. */ + useValue?: T; + /** Class instantiated by the container. */ + useClass?: AbstractType; + /** Factory function, dependencies are resolved through `inject`. */ + useFactory?: (...dependencies: unknown[]) => T; + /** Tokens handed to `useFactory` (or used for `useClass` overrides). */ + inject?: Token[]; + /** Lifetime of the provider. */ + scope?: ProviderScope; +} + +interface FactoryEntry { + token: Token; + scope: ProviderScope; + owner: Container; + produce: (scope?: Container) => unknown; + /** Asynchronous variant, used by {@link Container.resolveAsync}. */ + produceAsync?: (scope?: Container) => Promise; +} + +export const PARAM_TYPES_METADATA = "design:paramtypes"; +export const INJECT_TOKENS_METADATA = "muddle:inject"; +export const OPTIONAL_PARAMS_METADATA = "muddle:optional"; + +interface InjectEntry { + index: number; + token: Token; +} + +/** Reads the `@Inject()` overrides of a constructor. */ +export function getInjectTokens(target: object): Map { + const entries = getLatestMetadata(INJECT_TOKENS_METADATA, target) as + | InjectEntry[] + | undefined; + const map = new Map(); + for (const entry of entries ?? []) { + map.set(entry.index, entry.token); + } + return map; +} + +/** Reads the parameters marked with `@Optional()`. */ +export function getOptionalParams(target: object): Set { + const entries = getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as + | number[] + | undefined; + return new Set(entries ?? []); +} + +export class Container { + private readonly factories = new Map(); + readonly instances = new Map(); + + /** Called when a token cannot be found in this container or its parents. */ + fallbackResolver?: (token: Token, scope?: Container) => unknown; + + constructor(readonly parent?: Container) {} + + /** Creates a child container that shares factories but isolates instances. */ + createScope(): Container { + const scope = new Container(this); + scope.fallbackResolver = (token, inner) => + this.lookupFallback(token, inner); + return scope; + } + + /** Registers a factory entry. */ + set(token: Token, entry: Omit): void { + this.factories.set(token, { ...entry, token, owner: this }); + } + + /** `true` when the token is known to this container or one of its parents. */ + has(token: Token): boolean { + return this.findEntry(token) !== undefined; + } + + /** Every token known to this container. */ + tokens(): Token[] { + return [...this.factories.keys()]; + } + + /** + * Resolves a token. + * Providers built with an asynchronous factory return a promise, use + * {@link resolveAsync} to await them. + */ + resolve(token: Token, scope?: Container): T { + const entry = this.findEntry(token); + if (!entry) { + const fallback = this.lookupFallback(token, scope); + if (fallback !== undefined) return fallback as T; + throw new UnknownProviderError(token); + } + + if (entry.scope === "transient") { + return entry.produce(scope) as T; + } + + const cache = entry.scope === "request" ? (scope ?? this) : entry.owner; + const cached = cache.instances.get(token); + if (cached !== undefined) return cached as T; + + const value = entry.produce(scope); + cache.instances.set(token, value); + return value as T; + } + + /** Resolves a token, awaits async providers. */ + async resolveAsync(token: Token, scope?: Container): Promise { + const entry = this.findEntry(token); + if (!entry) { + const fallback = this.lookupFallback(token, scope); + if (fallback !== undefined) return (await fallback) as T; + throw new UnknownProviderError(token); + } + + if (entry.scope === "transient") { + const value = entry.produceAsync + ? await entry.produceAsync(scope) + : await entry.produce(scope); + return value as T; + } + + const cache = entry.scope === "request" ? (scope ?? this) : entry.owner; + const cached = cache.instances.get(token); + if (cached !== undefined) return (await cached) as T; + + const value = entry.produceAsync + ? await entry.produceAsync(scope) + : await entry.produce(scope); + cache.instances.set(token, value); + return value as T; + } + + private findEntry(token: Token): FactoryEntry | undefined { + const own = this.factories.get(token); + if (own) return own; + return this.parent?.findEntry(token); + } + + private lookupFallback(token: Token, scope?: Container): unknown { + const fallback = this.fallbackResolver ?? this.parent?.fallbackResolver; + if (!fallback) return undefined; + return fallback(token, scope); + } + + /** Registers a class, resolving its constructor dependencies automatically. */ + registerClass( + ctor: AbstractType, + options: { scope?: ProviderScope; inject?: Token[] } = {}, + ): void { + this.set(ctor as Token, { + scope: options.scope ?? "singleton", + produce: (scope) => this.instantiate(ctor, options.inject, scope), + produceAsync: async (scope) => + this.instantiateAsync(ctor, options.inject, scope), + }); + } + + /** Registers a plain value. */ + registerValue(token: Token, value: T): void { + this.set(token as Token, { scope: "singleton", produce: () => value }); + } + + /** Registers a factory function. */ + registerFactory( + token: Token, + factory: (...dependencies: unknown[]) => T | Promise, + options: { inject?: Token[]; scope?: ProviderScope } = {}, + ): void { + this.set(token as Token, { + scope: options.scope ?? "singleton", + produce: (scope) => { + const dependencies = (options.inject ?? []).map((dependency) => + this.resolve(dependency, scope), + ); + return factory(...dependencies); + }, + produceAsync: async (scope) => { + const dependencies = await Promise.all( + (options.inject ?? []).map((dependency) => + this.resolveAsync(dependency, scope), + ), + ); + return factory(...dependencies); + }, + }); + } + + /** Registers a provider definition. */ + registerProvider(provider: ProviderDefinition): void { + if (provider.useValue !== undefined) { + this.registerValue( + (provider.provide ?? provider.useClass) as Token, + provider.useValue, + ); + return; + } + if (provider.useFactory) { + const token = provider.provide ?? provider.useFactory; + this.registerFactory(token as Token, provider.useFactory, { + inject: provider.inject, + scope: provider.scope, + }); + return; + } + if (provider.useClass) { + this.registerClass(provider.useClass, { + scope: provider.scope, + inject: provider.inject, + }); + return; + } + throw new UnknownProviderError(provider.provide); + } + + /** Instantiates a class, resolving constructor parameters by type. */ + instantiate( + ctor: AbstractType, + inject?: Token[], + scope?: Container, + ): T { + const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ?? + []) as unknown[]; + const overrides = getInjectTokens(ctor as object); + const optional = getOptionalParams(ctor as object); + const dependencies: unknown[] = []; + + const length = Math.max( + paramTypes.length, + overrides.size, + inject?.length ?? 0, + ); + for (let index = 0; index < length; index++) { + const token = + overrides.get(index) ?? + inject?.[index] ?? + (paramTypes[index] as Token | undefined); + if (token === undefined || token === Object || token === Function) { + if (optional.has(index)) { + dependencies.push(undefined); + continue; + } + throw new UnknownProviderError( + `${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`, + ); + } + dependencies.push(this.resolve(token, scope)); + } + + return new (ctor as unknown as new (...args: unknown[]) => T)( + ...dependencies, + ); + } + + /** Instantiates a class and awaits asynchronous dependencies. */ + async instantiateAsync( + ctor: AbstractType, + inject?: Token[], + scope?: Container, + ): Promise { + const paramTypes = (getMetadata(PARAM_TYPES_METADATA, ctor) ?? + []) as unknown[]; + const overrides = getInjectTokens(ctor as object); + const optional = getOptionalParams(ctor as object); + const dependencies: unknown[] = []; + + const length = Math.max( + paramTypes.length, + overrides.size, + inject?.length ?? 0, + ); + for (let index = 0; index < length; index++) { + const token = + overrides.get(index) ?? + inject?.[index] ?? + (paramTypes[index] as Token | undefined); + if (token === undefined || token === Object || token === Function) { + if (optional.has(index)) { + dependencies.push(undefined); + continue; + } + throw new UnknownProviderError( + `${ctor.name} parameter #${index} (use @Inject() to provide an explicit token)`, + ); + } + dependencies.push(await this.resolveAsync(token, scope)); + } + + return new (ctor as unknown as new (...args: unknown[]) => T)( + ...dependencies, + ); + } +} diff --git a/core/content.ts b/core/content.ts new file mode 100644 index 0000000..43d1554 --- /dev/null +++ b/core/content.ts @@ -0,0 +1,163 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import type { + CollectionConfig, + CollectionEntry, + ContentSchema, + LoadedFile, + ResolvedConfig, +} from "../contracts/index.ts"; +import { Service } from "../decorators/Service.ts"; + +/** Validates the frontmatter of an entry against the schema of a collection. */ +export function validateFrontmatter( + schema: ContentSchema | undefined, + frontmatter: Record, +): string[] { + if (!schema) return []; + const errors: string[] = []; + + for (const field of schema.required ?? []) { + const value = frontmatter[field]; + if (value === undefined || value === "") { + errors.push(`${field} is required`); + } + } + + for (const [field, allowed] of Object.entries(schema.enum ?? {})) { + const value = frontmatter[field]; + if (value === undefined) continue; + if (!allowed.includes(String(value))) { + errors.push(`${field} must be one of ${allowed.join(", ")}`); + } + } + + for (const [field, length] of Object.entries(schema.maxLength ?? {})) { + const value = frontmatter[field]; + if (value === undefined) continue; + if (String(value).length > length) { + errors.push(`${field} must be at most ${length} characters`); + } + } + + return errors; +} + +/** Registry of the content collections of a project. */ +export class ContentRegistry { + private readonly collections = new Map(); + + constructor(private readonly config: ResolvedConfig) {} + + /** Loads every collection of the project. */ + async load(): Promise { + for (const collection of this.config.collections ?? []) { + this.collections.set( + collection.name, + await this.loadCollection(collection), + ); + } + } + + /** Entries of a collection. */ + entries = Record>( + name: string, + ): CollectionEntry[] { + return (this.collections.get(name) ?? []) as CollectionEntry[]; + } + + /** A single entry of a collection. */ + get = Record>( + name: string, + id: string, + ): CollectionEntry | undefined { + return this.entries(name).find((entry) => entry.id === id); + } + + /** Names of the loaded collections. */ + names(): string[] { + return [...this.collections.keys()]; + } + + private async loadCollection( + collection: CollectionConfig, + ): Promise { + const dir = resolve(this.config.root, collection.dir); + if (!existsSync(dir)) return []; + + const loader = + typeof collection.loader === "string" + ? this.config.loaders[collection.loader] + : collection.loader; + if (!loader) { + throw new Error( + `Unknown loader "${collection.loader}" of the collection "${collection.name}"`, + ); + } + + const files = await listCollectionFiles(dir, loader.extensions); + const entries: CollectionEntry[] = []; + + for (const file of files.sort()) { + const path = join(dir, file); + const source = await readFile(path, "utf8"); + const loaded: LoadedFile = await loader.load({ + path, + relativePath: relative(this.config.root, path).replace(/\\/g, "/"), + source, + }); + + const errors = validateFrontmatter(collection.schema, loaded.data); + if (errors.length > 0) { + throw new Error( + `Invalid frontmatter in ${relative(this.config.root, path)}:\n${errors + .map((error) => ` - ${error}`) + .join("\n")}`, + ); + } + + const id = file.replace(/\.[^.]+$/, "").replace(/\\/g, "/"); + entries.push({ + id, + path: relative(this.config.root, path).replace(/\\/g, "/"), + frontmatter: loaded.data, + body: loaded.content ?? "", + content: source, + }); + } + + return entries; + } +} + +async function listCollectionFiles(dir: string, extensions: string[], prefix = ""): Promise { + const files: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const relativePath = join(prefix, entry.name); + if (entry.isDirectory()) files.push(...(await listCollectionFiles(join(dir, entry.name), extensions, relativePath))); + else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(relativePath); + } + return files.sort(); +} + +/** Service exposing the content collections to the controllers. */ +@Service() +export class ContentService { + constructor(private readonly registry: ContentRegistry) {} + + /** Entries of a collection. */ + list = Record>( + name: string, + ): CollectionEntry[] { + return this.registry.entries(name); + } + + /** A single entry of a collection. */ + get = Record>( + name: string, + id: string, + ): CollectionEntry | undefined { + return this.registry.get(name, id); + } +} diff --git a/core/context.ts b/core/context.ts new file mode 100644 index 0000000..4657acf --- /dev/null +++ b/core/context.ts @@ -0,0 +1,24 @@ +import type { Headers } from "../contracts/typegen/http/headers.ts"; + +export class ServerBuildContext { + private readonly values: { headers: Headers; locals: Record } = { headers: {}, locals: {} }; + + header(name: string, value: string): this { this.values.headers[name.toLowerCase()] = value; return this; } + headers(values: Headers): this { Object.assign(this.values.headers, values); return this; } + local(name: string, value: unknown): this { this.values.locals[name] = value; return this; } + build(): Readonly<{ headers: Headers; locals: Record }> { return { headers: { ...this.values.headers }, locals: { ...this.values.locals } }; } +} + +export class TemplateContext { + readonly imports = new Map, context: { locals: Record; params: Record }): Promise | string }>(); + import(name: string, component: { render(props: Record, context: { locals: Record; params: Record }): Promise | string }): this { + if (!/^[A-Za-z][\w-]*$/.test(name)) throw new Error("Invalid component import name"); + this.imports.set(name, component); + return this; + } + private readonly values: { templates: string[]; inputs: Record; contracts: Record } = { templates: [], inputs: {}, contracts: {} }; + template(path: string): this { this.values.templates.push(path); return this; } + input(name: string, value: unknown): this { this.values.inputs[name] = value; return this; } + contract(name: string, type: string): this { this.values.contracts[name] = type; return this; } + build(): Readonly<{ templates: string[]; inputs: Record; contracts: Record }> { return { templates: [...this.values.templates], inputs: { ...this.values.inputs }, contracts: { ...this.values.contracts } }; } +} diff --git a/core/errors.ts b/core/errors.ts new file mode 100644 index 0000000..9e2a92f --- /dev/null +++ b/core/errors.ts @@ -0,0 +1,46 @@ +/** Base error for every failure raised by the framework. */ +export class MuddleError extends Error { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "MuddleError"; + } +} + +/** Raised when a dependency cannot be resolved from the current module graph. */ +export class UnknownProviderError extends MuddleError { + constructor(token: unknown) { + super(`Cannot resolve dependency: ${describeToken(token)}`); + this.name = "UnknownProviderError"; + } +} + +/** Raised when a request does not match any route. */ +export class RouteNotFoundError extends MuddleError { + constructor(method: string, path: string) { + super(`No route matches ${method} ${path}`); + this.name = "RouteNotFoundError"; + } +} + +/** Raised while compiling or rendering a template. */ +export class TemplateError extends MuddleError { + constructor(message: string, options?: { cause?: unknown }) { + super(message, options); + this.name = "TemplateError"; + } +} + +/** Raised when the ORM cannot find a table definition. */ +export class SchemaError extends MuddleError { + constructor(message: string) { + super(message); + this.name = "SchemaError"; + } +} + +/** Human readable representation of an injection token. */ +export function describeToken(token: unknown): string { + if (typeof token === "function") return token.name || "anonymous class"; + if (typeof token === "symbol") return token.toString(); + return String(token); +} diff --git a/core/form.ts b/core/form.ts new file mode 100644 index 0000000..023a1f3 --- /dev/null +++ b/core/form.ts @@ -0,0 +1,145 @@ +import type { FormFields } from "../contracts/typegen/http/request.ts"; + +/** Reads the fields of a parsed form body. */ +export function toFormFields(body: unknown): FormFields { + const source = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : {}; + return { + get(name: string) { + const value = source[name]; + if (value === undefined || value === null) return undefined; + return Array.isArray(value) ? String(value[0]) : String(value); + }, + all() { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + result[key] = Array.isArray(value) + ? value.map((entry) => String(entry)) + : String(value); + } + return result; + }, + has(name: string) { + return name in source; + }, + values() { + const result: Record = {}; + for (const [key, value] of Object.entries(source)) { + result[key] = Array.isArray(value) + ? String(value[0] ?? "") + : String(value); + } + return result; + }, + }; +} + +/** Rules of a single form field. */ +export interface FieldRule { + /** The field has to be filled in. */ + required?: boolean; + /** Type of the field. */ + type?: "string" | "number" | "boolean" | "email"; + /** Minimum value of a number field. */ + min?: number; + /** Maximum value of a number field. */ + max?: number; + /** Maximum length of a string field. */ + maxLength?: number; + /** Allowed values. */ + enum?: string[]; + /** Value used when the field is empty. */ + default?: unknown; +} + +export type FormSchema = Record; + +/** Result of a validation. */ +export type ValidationResult = + | { ok: true; value: T; errors: Record } + | { ok: false; value: undefined; errors: Record }; + +const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Validates the fields of a form. */ +export function validate>( + fields: FormFields, + schema: FormSchema, +): ValidationResult { + const errors: Record = {}; + const value: Record = {}; + + for (const [name, rule] of Object.entries(schema)) { + const raw = fields.get(name); + const empty = raw === undefined || raw.trim().length === 0; + + if (empty) { + if (rule.default !== undefined) { + value[name] = rule.default; + continue; + } + if (rule.required) { + errors[name] = `${name} is required`; + } + continue; + } + + let parsed: unknown = raw; + if (rule.type === "number") { + const numeric = Number(raw); + if (Number.isNaN(numeric)) { + errors[name] = `${name} must be a number`; + continue; + } + if (rule.min !== undefined && numeric < rule.min) { + errors[name] = `${name} must be at least ${rule.min}`; + continue; + } + if (rule.max !== undefined && numeric > rule.max) { + errors[name] = `${name} must be at most ${rule.max}`; + continue; + } + parsed = numeric; + } else if (rule.type === "boolean") { + parsed = raw === "on" || raw === "true" || raw === "1"; + } else if (rule.type === "email") { + if (!EMAIL.test(raw)) { + errors[name] = `${name} must be a valid email address`; + continue; + } + } + + if ( + rule.maxLength !== undefined && + String(parsed).length > rule.maxLength + ) { + errors[name] = `${name} must be at most ${rule.maxLength} characters`; + continue; + } + if (rule.enum && !rule.enum.includes(String(parsed))) { + errors[name] = `${name} must be one of ${rule.enum.join(", ")}`; + continue; + } + + value[name] = parsed; + } + + if (Object.keys(errors).length > 0) { + return { ok: false, value: undefined, errors }; + } + return { ok: true, value: value as T, errors }; +} + +/** + * Builds the location of a redirect that carries a message. + */ +export function redirectWithMessage( + location: string, + message?: string, +): string { + if (!message) return location; + const separator = location.includes("?") ? "&" : "?"; + return `${location}${separator}message=${encodeURIComponent(message)}`; +} diff --git a/core/format-source.ts b/core/format-source.ts new file mode 100644 index 0000000..3120e4b --- /dev/null +++ b/core/format-source.ts @@ -0,0 +1,63 @@ +import { foldLines } from "../views/engine/lexer.ts"; + +export interface FormatOptions { tabWidth?: number; useTabs?: boolean; printWidth?: number } +export type FormatLanguage = "spug" | "stpug" | "stug" | "stcss"; + +/** Shared by the CLI and Prettier plugin, with no subprocesses or write side effects. */ +export async function formatSource(source: string, language: FormatLanguage, options: FormatOptions = {}): Promise { + const prettier = await import("prettier"); + const scripts: string[] = []; + let protectedSource = source.replace(/(^[\t ]*)(javascript!|typescript!|---)\s*\n([\s\S]*?)^\s*(end|---)\s*$/gm, (_match, indentation: string, directive: string, code: string, ending: string) => { + const index = scripts.length; + scripts.push(JSON.stringify({ directive, code, ending })); + return language === "stcss" ? `/* MUDDLE_SCRIPT_${index} */` : `${indentation}MUDDLE_SCRIPT_${index}`; + }); + const rawBlocks: { marker: string; body: string[]; prefix: string }[] = []; + if (language !== "stcss") { + const sourceLines = protectedSource.split("\n"); + const output: string[] = []; + for (let index = 0; index < sourceLines.length; index++) { + const line = sourceLines[index]!; + const match = /^(\s*)(?:[\w.#-]+(?:\([^]*\))?\.|\/\/-?|\$\$\$)\s*$/.exec(line); + if (!match) { output.push(line); continue; } + const prefix = match[1]!; + const body: string[] = [line.slice(prefix.length)]; + while (index + 1 < sourceLines.length) { + const next = sourceLines[index + 1]!; + if (next.trim() && (next.match(/^\s*/)?.[0].length ?? 0) <= prefix.length) break; + body.push(next.startsWith(prefix) ? next.slice(prefix.length) : next); + index++; + } + const marker = `MUDDLE_RAW_${rawBlocks.length}`; + rawBlocks.push({ marker, body, prefix }); + output.push(prefix + marker); + } + protectedSource = output.join("\n"); + } + const unit = options.useTabs ? "\t" : " ".repeat(options.tabWidth ?? 2); + let formatted: string; + if (language === "stcss") { + formatted = await prettier.format(protectedSource, { parser: "scss", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false, printWidth: options.printWidth ?? 80 }); + } else { + const stack = [0]; + const lines: string[] = []; + for (const line of foldLines(protectedSource)) { + while (stack.length > 1 && line.indent < stack[stack.length - 1]!) stack.pop(); + if (line.indent > stack[stack.length - 1]!) stack.push(line.indent); + const indent = unit.repeat(stack.length - 1); + lines.push(`${indent}${line.text.trimEnd()}`); + } + formatted = `${lines.join("\n")}\n`; + } + for (let index = 0; index < scripts.length; index++) { + const script = JSON.parse(scripts[index]!) as { directive: string; code: string; ending: string }; + const code = await prettier.format(script.code, { parser: "typescript", plugins: [], tabWidth: options.tabWidth ?? 2, useTabs: options.useTabs ?? false }); + const marker = language === "stcss" ? new RegExp(`^[\\t ]*/\\* MUDDLE_SCRIPT_${index} \\*/`, "m") : new RegExp(`^([\\t ]*)MUDDLE_SCRIPT_${index}`, "m"); + formatted = formatted.replace(marker, (match) => { + const indentation = match.match(/^[\t ]*/)?.[0] ?? ""; + return `${indentation}${script.directive}\n${code.trimEnd().split("\n").map((line) => `${indentation}${line}`).join("\n")}\n${indentation}${script.ending}`; + }); + } + for (const block of rawBlocks) formatted = formatted.replace(new RegExp(`^([\\t ]*)${block.marker}$`, "m"), (_match, prefix: string) => block.body.map((line) => prefix + line).join("\n")); + return formatted; +} diff --git a/core/format.ts b/core/format.ts new file mode 100644 index 0000000..6fe6a02 --- /dev/null +++ b/core/format.ts @@ -0,0 +1,22 @@ +import { readdir, readFile, writeFile, stat } from "node:fs/promises"; +import { join, extname } from "node:path"; +import { formatSource, type FormatLanguage, type FormatOptions } from "./format-source.ts"; + +export async function format(root: string, options: FormatOptions & { check?: boolean } = {}): Promise { + const changed: string[] = []; + if ((await stat(root)).isFile()) { + const language = extname(root).slice(1) as FormatLanguage; + if (!["spug", "stpug", "stug", "stcss"].includes(language)) return changed; + const source = await readFile(root, "utf8"); + const next = await formatSource(source, language, options); + if (next !== source) { changed.push(root); if (!options.check) await writeFile(root, next, "utf8"); } + return changed; + } + for (const entry of await readdir(root, { withFileTypes: true })) { + if (entry.isSymbolicLink()) continue; + if (entry.name.startsWith(".") || ["node_modules", "dist", "dist.previous", "shadcn-svelte", "_muddle"].includes(entry.name)) continue; + const path = join(root, entry.name); + changed.push(...await format(path, options)); + } + return changed; +} diff --git a/core/http.ts b/core/http.ts new file mode 100644 index 0000000..3d9c1de --- /dev/null +++ b/core/http.ts @@ -0,0 +1,239 @@ +import type { + Headers, + HttpResponse, + ResponseBuilder, + StatusCode, +} from "../contracts/typegen/http/index.ts"; +import { + FORM_CONTENT_TYPE, + HTML_CONTENT_TYPE, + JSON_CONTENT_TYPE, + TEXT_CONTENT_TYPE, + mediaType, +} from "../contracts/typegen/http/index.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import { toFormFields } from "./form.ts"; +import { existsSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { extname, resolve } from "node:path"; + +/** Creates an HTML response. */ +export function html( + body: string, + status: StatusCode = 200, +): HttpResponse { + return { status, headers: { "content-type": HTML_CONTENT_TYPE }, body }; +} + +/** Creates a JSON response. */ +export function json( + body: unknown, + status: StatusCode = 200, +): HttpResponse { + return { status, headers: { "content-type": JSON_CONTENT_TYPE }, body }; +} + +/** Creates a plain text response. */ +export function text( + body: string, + status: StatusCode = 200, +): HttpResponse { + return { status, headers: { "content-type": TEXT_CONTENT_TYPE }, body }; +} + +/** Creates a redirect response. */ +export function redirect( + location: string, + status: StatusCode = 302, +): HttpResponse { + return { status, headers: { location }, body: undefined as never }; +} + +/** Creates a `404` response. */ +export function notFound(message = "Not Found"): HttpResponse { + return text(message, 404); +} + +/** Creates a response builder bound to a status code and a set of headers. */ +export function createResponseBuilder( + initialStatus: StatusCode = 200, + initialHeaders: Headers = {}, +): ResponseBuilder { + let status = initialStatus; + const headers: Headers = { ...initialHeaders }; + const builder: ResponseBuilder = { + html(body, code) { + return { + status: code ?? status, + headers: { ...headers, "content-type": HTML_CONTENT_TYPE }, + body, + }; + }, + json(body, code) { + return { + status: code ?? status, + headers: { ...headers, "content-type": JSON_CONTENT_TYPE }, + body, + }; + }, + text(body, code) { + return { + status: code ?? status, + headers: { ...headers, "content-type": TEXT_CONTENT_TYPE }, + body, + }; + }, + redirect(location, code) { + return { + status: code ?? 302, + headers: { ...headers, location }, + body: undefined as never, + }; + }, + status(code) { + status = code as StatusCode; + return builder; + }, + header(name, value) { + headers[name.toLowerCase()] = value; + return builder; + }, + send(body) { + return { status, headers: { ...headers }, body }; + }, + }; + return builder; +} + +/** Parses the body of a fetch `Request` according to its content type. */ +export async function parseRequestBody(request: Request): Promise { + const type = mediaType(request.headers.get("content-type") ?? ""); + if (request.method === "GET" || request.method === "HEAD") return undefined; + if (type === "application/json") { + const raw = await request.text(); + if (!raw) return undefined; + try { + return JSON.parse(raw); + } catch (cause) { + throw new Error(`Invalid JSON body: ${(cause as Error).message}`, { + cause, + }); + } + } + if (type === FORM_CONTENT_TYPE) { + const form = await request.formData(); + const result: Record = {}; + for (const [key, value] of form.entries()) { + result[key] = typeof value === "string" ? value : value.name; + } + return result; + } + if (type === "text/plain") { + return await request.text(); + } + if ( + type === "" || + type === "multipart/form-data" || + type === "application/octet-stream" + ) { + const buffer = await request.arrayBuffer(); + return buffer.byteLength === 0 ? undefined : buffer; + } + return await request.text(); +} + +/** Parses a query string into a plain object. */ +export function parseQuery(search: string): Record { + const params = new URLSearchParams(search); + const result: Record = {}; + for (const [key, value] of params.entries()) { + result[key] = value; + } + return result; +} + +/** Lower-cases every header name of a header record. */ +export function normalizeHeaders( + headers: Record, +): Record { + const result: Record = {}; + for (const [name, value] of Object.entries(headers)) { + result[name.toLowerCase()] = value; + } + return result; +} + +/** Content type of a file extension. */ +export function contentTypeOf(extension: string): string { + switch (extension) { + case ".html": + case ".htm": + return "text/html; charset=utf-8"; + case ".css": + return "text/css; charset=utf-8"; + case ".js": + case ".mjs": + return "text/javascript; charset=utf-8"; + case ".json": + return "application/json; charset=utf-8"; + case ".svg": + return "image/svg+xml"; + case ".png": + return "image/png"; + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".webp": + return "image/webp"; + case ".ico": + return "image/x-icon"; + case ".txt": + return "text/plain; charset=utf-8"; + case ".woff2": + return "font/woff2"; + default: + return "application/octet-stream"; + } +} + +/** Serves a file of a static root. */ +export async function serveStaticFile( + root: string, + path: string, +): Promise { + const relative = decodeURIComponent(path.split("?")[0] ?? ""); + const target = resolve(root, `.${relative.startsWith("/") ? relative : `/${relative}`}`); + if (!target.startsWith(resolve(root))) return undefined; + if (!existsSync(target)) return undefined; + if (!statSync(target).isFile()) return undefined; + + const file = await readFile(target); + return { + status: 200, + headers: { "content-type": contentTypeOf(extname(target)) }, + body: new Uint8Array(file), + }; +} + +/** Builds the request context handed to controllers. */ +export function createRequestContext(request: Request): RequestContext { + const url = new URL(request.url); + const headers = normalizeHeaders( + Object.fromEntries(request.headers.entries()), + ); + const context: RequestContext = { + method: request.method as RequestContext["method"], + url: request.url, + path: url.pathname, + params: {}, + query: parseQuery(url.search), + body: undefined, + form: toFormFields(undefined), + headers, + signal: request.signal, + locals: {}, + raw: request, + respond: createResponseBuilder(), + }; + return context; +} diff --git a/core/index.ts b/core/index.ts new file mode 100644 index 0000000..a9c0c27 --- /dev/null +++ b/core/index.ts @@ -0,0 +1,7 @@ +export * from "./app.ts"; +export * from "./container.ts"; +export * from "./errors.ts"; +export * from "./http.ts"; +export * from "./metadata.ts"; +export * from "./modules.ts"; +export * from "./router.ts"; diff --git a/core/metadata.ts b/core/metadata.ts new file mode 100644 index 0000000..8dcbbd4 --- /dev/null +++ b/core/metadata.ts @@ -0,0 +1,179 @@ +export type MetadataKey = string | symbol; + +type MetadataMap = Map; +type PropertyMetadataMap = Map; + +const metadataStore = new WeakMap(); +const propertyMetadataStore = new WeakMap(); + +function mapFor(target: object): MetadataMap { + let map = metadataStore.get(target); + if (!map) { + map = new Map(); + metadataStore.set(target, map); + } + return map; +} + +function propertyMapFor(target: object, propertyKey: MetadataKey): MetadataMap { + let properties = propertyMetadataStore.get(target); + if (!properties) { + properties = new Map(); + propertyMetadataStore.set(target, properties); + } + let map = properties.get(propertyKey); + if (!map) { + map = new Map(); + properties.set(propertyKey, map); + } + return map; +} + +/** Stores `metadataValue` for `metadataKey` on `target` (and `propertyKey`). */ +export function defineMetadata( + metadataKey: MetadataKey, + metadataValue: unknown, + target: object, + propertyKey?: MetadataKey, +): void { + const map = mapFor(target); + const existing = map.get(metadataKey) ?? []; + existing.push(metadataValue); + map.set(metadataKey, existing); + if (propertyKey !== undefined) { + const propertyMap = propertyMapFor(target, propertyKey); + const propertyExisting = propertyMap.get(metadataKey) ?? []; + propertyExisting.push(metadataValue); + propertyMap.set(metadataKey, propertyExisting); + } +} + +/** Reads metadata declared directly on `target`. */ +export function getOwnMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): unknown | undefined { + if (propertyKey !== undefined) { + const properties = propertyMetadataStore.get(target); + return properties?.get(propertyKey)?.get(metadataKey)?.[0]; + } + return metadataStore.get(target)?.get(metadataKey)?.[0]; +} + +/** Reads metadata declared on `target` or inherited from its prototype chain. */ +export function getMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): unknown | undefined { + let current: object | null = target; + while (current) { + const value = getOwnMetadata(metadataKey, current, propertyKey); + if (value !== undefined) return value; + current = Object.getPrototypeOf(current); + } + return undefined; +} + +/** `true` when metadata is declared on `target` or an ancestor. */ +export function hasMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): boolean { + return getMetadata(metadataKey, target, propertyKey) !== undefined; +} + +/** Removes all metadata registered for `target`. */ +export function deleteMetadata(target: object): void { + metadataStore.delete(target); +} + +/** Reads the most recent metadata entry declared for `target`. */ +export function getLatestMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey?: MetadataKey, +): unknown | undefined { + if (propertyKey !== undefined) { + const properties = propertyMetadataStore.get(target); + const entries = properties?.get(propertyKey)?.get(metadataKey); + return entries?.[entries.length - 1]; + } + const entries = metadataStore.get(target)?.get(metadataKey); + return entries?.[entries.length - 1]; +} + +/** Owner of a decorated member. */ +export function ownerOf(target: object): object { + if (typeof target === "function") return target as object; + return (target as { constructor: object }).constructor; +} + +/** Stores metadata of a method or a parameter on the owning class. */ +export function defineMemberMetadata( + metadataKey: MetadataKey, + metadataValue: unknown, + target: object, + propertyKey?: MetadataKey, +): void { + defineMetadata(metadataKey, metadataValue, ownerOf(target), propertyKey); +} + +/** Reads every metadata entry stored for a method. */ +export function getMemberMetadata( + metadataKey: MetadataKey, + target: object, + propertyKey: MetadataKey, +): unknown[] { + const properties = propertyMetadataStore.get(target); + const map = properties?.get(propertyKey); + if (!map) return []; + return map.get(metadataKey) ?? []; +} + +/** Reads every metadata entry of a class, together with its property key. */ +export function getAllMemberMetadata( + metadataKey: MetadataKey, + target: object, +): { propertyKey: MetadataKey; value: unknown }[] { + const properties = propertyMetadataStore.get(target); + if (!properties) return []; + const entries: { propertyKey: MetadataKey; value: unknown }[] = []; + for (const [propertyKey, map] of properties) { + for (const value of map.get(metadataKey) ?? []) { + entries.push({ propertyKey, value }); + } + } + return entries; +} + +/** Installs the proposal API on `Reflect` when the runtime does not provide it. */ +export function installMetadataPolyfill(): void { + const reflect = Reflect as unknown as Record; + if (typeof reflect["metadata"] !== "function") { + reflect["metadata"] = ( + metadataKey: MetadataKey, + metadataValue: unknown, + ) => { + return (target: object, propertyKey?: MetadataKey): void => { + defineMetadata(metadataKey, metadataValue, target, propertyKey); + }; + }; + } + if (typeof reflect["defineMetadata"] !== "function") { + reflect["defineMetadata"] = defineMetadata; + } + if (typeof reflect["getOwnMetadata"] !== "function") { + reflect["getOwnMetadata"] = getOwnMetadata; + } + if (typeof reflect["getMetadata"] !== "function") { + reflect["getMetadata"] = getMetadata; + } + if (typeof reflect["hasMetadata"] !== "function") { + reflect["hasMetadata"] = hasMetadata; + } +} + +installMetadataPolyfill(); diff --git a/core/modules.ts b/core/modules.ts new file mode 100644 index 0000000..f93fe44 --- /dev/null +++ b/core/modules.ts @@ -0,0 +1,154 @@ +import { + Container, + type AbstractType, + type ProviderDefinition, + type Token, + type Type, +} from "./container.ts"; +import { + getInjectableOptions, + getModuleOptions, +} from "../decorators/Module.ts"; + +export interface ModuleContext { + type: Type; + container: Container; + exports: Token[]; + controllers: Type[]; + imports: ModuleContext[]; +} + +/** Per request container graph, one scoped container per module. */ +export class ScopeGraph { + private readonly containers = new Map(); + + constructor(private readonly registry: ModuleRegistry) { + for (const context of registry.all()) { + const scope = context.container.createScope(); + scopeGraphs.set(scope, this); + this.containers.set(context, scope); + } + } + + /** Scoped container of a module. */ + containerFor(context: ModuleContext): Container { + const container = this.containers.get(context); + if (!container) + throw new Error(`Module ${context.type.name} is not registered`); + return container; + } +} + +const scopeGraphs = new WeakMap(); + +export class ModuleRegistry { + private readonly contexts = new Map(); + private readonly order: ModuleContext[] = []; + + /** Every registered module, in registration order. */ + all(): ModuleContext[] { + return [...this.order]; + } + + /** Registers a module and all of its imports. */ + register(moduleType: Type): ModuleContext { + const existing = this.contexts.get(moduleType); + if (existing) return existing; + + const options = getModuleOptions(moduleType); + const context: ModuleContext = { + type: moduleType, + container: new Container(), + exports: [], + controllers: [...(options.controllers ?? [])], + imports: [], + }; + this.contexts.set(moduleType, context); + + for (const imported of options.imports ?? []) { + context.imports.push(this.register(imported)); + } + + for (const provider of options.providers ?? []) { + if (typeof provider === "function") { + const scope = getInjectableOptions(provider).scope; + context.container.registerClass(provider as AbstractType, { scope }); + } else { + context.container.registerProvider(provider as ProviderDefinition); + } + } + + for (const controller of context.controllers) { + context.container.registerClass(controller); + } + + context.exports = [...(options.exports ?? [])]; + context.container.fallbackResolver = (token, scope) => + this.resolveFromImports(context, token, scope); + + this.order.push(context); + return context; + } + + /** Resolves a token from the exported providers of the imported modules. */ + resolveFromImports( + context: ModuleContext, + token: Token, + scope?: Container, + ): unknown { + for (const imported of context.imports) { + const resolved = this.resolveExported(imported, token, scope); + if (resolved !== undefined) return resolved; + } + return undefined; + } + + /** Resolves a token that an imported module re-exports. */ + resolveExported( + context: ModuleContext, + token: Token, + scope?: Container, + ): unknown { + if (context.exports.includes(token)) { + return context.container.resolve(token, scope); + } + for (const exported of context.exports) { + if (typeof exported !== "function") continue; + const exportedContext = this.contexts.get(exported as Type); + if (!exportedContext) continue; + const resolved = this.resolveExported(exportedContext, token, scope); + if (resolved !== undefined) return resolved; + } + return undefined; + } + + /** Controllers of every module together with the module they belong to. */ + controllers(): { context: ModuleContext; controller: Type }[] { + const result: { context: ModuleContext; controller: Type }[] = []; + for (const context of this.order) { + for (const controller of context.controllers) { + result.push({ context, controller }); + } + } + return result; + } + + /** Instantiates every controller, surfacing dependency errors at boot time. */ + bootstrap(): void { + for (const { context, controller } of this.controllers()) { + context.container.resolve(controller); + } + } + + /** Instantiates every controller asynchronously. */ + async bootstrapAsync(): Promise { + for (const { context, controller } of this.controllers()) { + await context.container.resolveAsync(controller); + } + } + + /** Creates the per request container graph. */ + createScope(): ScopeGraph { + return new ScopeGraph(this); + } +} diff --git a/core/paths.ts b/core/paths.ts new file mode 100644 index 0000000..bf537e0 --- /dev/null +++ b/core/paths.ts @@ -0,0 +1,81 @@ +import { dirname, isAbsolute, resolve } from "node:path"; + +/** Root directory of the framework package. */ +export const frameworkRoot = resolve(import.meta.dir, ".."); + +const INTERNAL_MARKERS = ["/bun:", "node:internal", "node_modules"]; + +export const frameworkSources = [ + "adapters", + "contracts", + "core", + "decorators", + "integrations", + "orm", + "views", +].map((folder) => resolve(frameworkRoot, folder)); + +/** Extracts the file path of a stack frame. */ +function frameFile(frame: string): string | undefined { + let text = frame.trim(); + text = text.replace(/^at\s+/, ""); + + const parenthesised = /\((.*)\)$/.exec(text); + if (parenthesised) text = parenthesised[1] as string; + + text = text.replace(/:\d+(?::\d+)?$/, ""); + if (text.length === 0) return undefined; + if (text.startsWith("file://")) return text.slice(7); + if (!text.startsWith("/")) return undefined; + return text; +} + +/** Returns the file that called the caller of this function. */ +export function callerFile( + skipDirs: string[] = frameworkSources, + skip = 0, +): string | undefined { + const stack = new Error("caller").stack; + if (!stack) return undefined; + const frames = stack.split("\n").slice(1); + let seen = 0; + for (const frame of frames) { + const file = frameFile(frame); + if (!file) continue; + if (INTERNAL_MARKERS.some((marker) => file.startsWith(marker))) continue; + if (skipDirs.some((dir) => file.startsWith(dir))) continue; + if (seen < skip) { + seen += 1; + continue; + } + return file; + } + return undefined; +} + +/** Directory of the file that called the caller of this function. */ +export function callerDirectory( + skipDirs: string[] = frameworkSources, + skip = 0, +): string | undefined { + const file = callerFile(skipDirs, skip + 1); + return file ? dirname(file) : undefined; +} + +/** Resolves a path relative to the project root. */ +export function fromRoot(root: string, path: string): string { + return isAbsolute(path) ? path : resolve(root, path); +} + +/** Normalises a path to posix separators, relative to the project root. */ +export function relativeTo(root: string, path: string): string { + const resolvedRoot = resolve(root); + const resolvedPath = resolve(path); + if (!resolvedPath.startsWith(resolvedRoot)) return resolvedPath; + return resolvedPath.slice(resolvedRoot.length + 1).replace(/\\/g, "/"); +} + +/** Removes the extension of a path. */ +export function stripExtension(path: string): string { + return path.replace(/\.[^./]+$/, ""); +} diff --git a/core/pipeline.ts b/core/pipeline.ts new file mode 100644 index 0000000..0f2fc60 --- /dev/null +++ b/core/pipeline.ts @@ -0,0 +1,14 @@ +import type { HttpResponse } from "../contracts/typegen/http/response.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import type { RouteDefinition } from "./router.ts"; + +export type RouteGuard = (context: RequestContext, route: RouteDefinition) => Promise | void | HttpResponse; + +export class RoutePipeline { + private readonly guards: RouteGuard[] = []; + use(guard: RouteGuard): this { this.guards.push(guard); return this; } + async run(context: RequestContext, route: RouteDefinition): Promise { + for (const guard of this.guards) { const result = await guard(context, route); if (result) return result; } + return undefined; + } +} diff --git a/core/router.ts b/core/router.ts new file mode 100644 index 0000000..8bf1254 --- /dev/null +++ b/core/router.ts @@ -0,0 +1,522 @@ +import { getControllerOptions, getControllerPrefix } from "../decorators/Controller.ts"; +import { getRoot, resolveViewRoot } from "../decorators/Root.ts"; +import { getParamMetadata, type ParamMetadata } from "../decorators/Params.ts"; +import { getRoutes, type RouteOptions } from "../decorators/Route.ts"; +import { getViews, type ViewMetadata } from "../decorators/View.ts"; +import type { + Headers, + HttpMethod, + HttpResponse, + StatusCode, +} from "../contracts/typegen/http/index.ts"; +import type { RenderedView } from "../contracts/typegen/http/response.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import { serveStaticFile } from "./http.ts"; +import { existsSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; +import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts"; +import { RoutePipeline } from "./pipeline.ts"; +import { getAuth } from "../decorators/Auth.ts"; + +/** Template rendered by a controller, the props are checked by the typegen. */ +export interface ViewDescriptor

{ + /** Template path, relative to the views directory or absolute. */ + template: P; + /** Props handed to the template. */ + props?: P extends ViewPath ? ViewPropsOf

: Record; + /** Status code of the response. */ + status?: StatusCode; + /** Headers of the response. */ + headers?: Headers; +} + +export interface RouterOptions { + /** Renders a view descriptor; injected by the application. */ + renderView?: ( + descriptor: ViewDescriptor, + context: RequestContext, + ) => Promise | string | RenderedView; + pipeline?: RoutePipeline; +} + +export interface RouteDefinition { + method: HttpMethod; + /** Full path, controller prefix included. */ + path: string; + controller: object; + methodName: string; + options: RouteOptions; + /** Template rendered by the route, declared with `@View()`. */ + view?: ResolvedView; + /** Invokes the controller method with the resolved arguments. */ + invoke: (context: RequestContext) => Promise; +} + +export type RouteMatch = + | { kind: "match"; route: RouteDefinition; params: Record } + | { kind: "method-not-allowed"; allowed: HttpMethod[] } + | { kind: "not-found" }; + +/** Joins a controller prefix and a route path into a canonical path. */ +export function joinPath(prefix: string, path: string): string { + const raw = `${prefix}/${path}`.replace(/\/{2,}/g, "/"); + const trimmed = raw.replace(/\/+$/, ""); + return trimmed.length === 0 ? "/" : trimmed; +} + +/** Splits a path into its segments. */ +export function pathSegments(path: string): string[] { + return path.split("/").filter((segment) => segment.length > 0); +} + +/** + * Matches the segments of a route against the segments of a request path. + */ +export function matchSegments( + routeSegments: string[], + requestSegments: string[], +): Record | null { + const params: Record = {}; + + for (let index = 0; index < routeSegments.length; index++) { + const segment = routeSegments[index] as string; + const actual = requestSegments[index]; + + if (segment === "*") { + params["*"] = requestSegments.slice(index).join("/"); + return params; + } + + if (segment.startsWith(":")) { + const optional = segment.endsWith("?"); + const name = segment.slice(1, optional ? -1 : undefined); + if (actual === undefined) { + if (optional) continue; + return null; + } + params[name] = actual; + continue; + } + + if (actual === undefined || segment !== actual) return null; + } + + if (requestSegments.length > routeSegments.length) return null; + return params; +} + +export interface RawRoute { + method: string; + path: string; + handler: ( + request: Request, + context: RequestContext, + ) => Promise | Response; +} + +export interface StaticRoot { + /** Prefix of the controller the files are served below. */ + prefix: string; + /** Absolute path of the directory. */ + path: string; +} + +/** A view of a controller, resolved against the view folder. */ +export interface ResolvedView { + /** Absolute path of the template. */ + path: string; + /** Route of the template, derived from its path. */ + route: string; + /** Name of the method the view belongs to. */ + propertyKey: string; + method: HttpMethod; + status?: StatusCode; + name?: string; + description?: string; + params?: Record; +} + +/** + * Resolves the template of a view. + */ +export function resolveView(view: ViewMetadata, viewRoot: string): ResolvedView { + const absolute = isAbsolute(view.path) + ? view.path + : resolve(viewRoot, view.path); + const path = existsSync(absolute) + ? absolute + : existsSync(`${absolute}.spug`) + ? `${absolute}.spug` + : absolute; + + let relative = path.startsWith(`${viewRoot}/`) + ? path.slice(viewRoot.length + 1) + : path.split("/").pop() ?? path; + relative = relative.replace(/\.[^./]+$/, ""); + + const segments = relative.split("/").filter((segment) => segment.length > 0); + if (segments.length > 0 && segments[segments.length - 1] === "index") { + segments.pop(); + } + + return { + path, + route: segments.length === 0 ? "/" : `/${segments.join("/")}`, + propertyKey: view.propertyKey, + method: view.method, + status: view.status, + name: view.name, + description: view.description, + params: view.params, + }; +} + +export class Router { + private readonly routes: RouteDefinition[] = []; + private readonly rawRoutes: RawRoute[] = []; + private readonly staticRoots: StaticRoot[] = []; + + constructor(private readonly options: RouterOptions = {}) {} + + /** + * Adds a route that is not bound to a controller. + */ + raw( + method: string, + path: string, + handler: RawRoute["handler"], + ): void { + this.rawRoutes.push({ method: method.toUpperCase(), path, handler }); + } + + /** Registers every route declared on a controller instance. */ + register(controllerType: object, instance: unknown): void { + const prefix = getControllerPrefix(controllerType); + const routes = getRoutes(controllerType); + const views = getViews(controllerType); + const viewRoot = resolveViewRoot( + controllerType, + getControllerOptions(controllerType).dir ?? process.cwd(), + ); + + const root = getRoot(controllerType); + if (root) { + this.staticRoots.push({ prefix, path: root.path }); + } + + for (const route of routes) { + const view = views.find( + (entry) => entry.propertyKey === route.propertyKey, + ); + this.routes.push({ + method: route.method, + path: joinPath(prefix, route.path), + controller: controllerType, + methodName: route.propertyKey, + options: route.options, + view: view ? resolveView(view, viewRoot) : undefined, + invoke: (context) => + this.invokeHandler( + instance, + route.propertyKey, + getParamMetadata(controllerType, route.propertyKey), + context, + ), + }); + } + + for (const view of views) { + if (routes.some((route) => route.propertyKey === view.propertyKey)) { + continue; + } + const resolved = resolveView(view, viewRoot); + this.routes.push({ + method: view.method, + path: joinPath(prefix, resolved.route ?? "/"), + controller: controllerType, + methodName: view.propertyKey, + options: {}, + view: resolved, + invoke: (context) => + this.invokeHandler( + instance, + view.propertyKey, + getParamMetadata(controllerType, view.propertyKey), + context, + ), + }); + } + } + + /** Every registered route definition. */ + list(): RouteDefinition[] { + return [...this.routes]; + } + + /** Every route that is not bound to a controller. */ + listRaw(): RawRoute[] { + return [...this.rawRoutes]; + } + + /** Public route metadata for client navigation and route-aware components. */ + clientRoutes(): { name: string; description?: string; path: string; method: HttpMethod; params: Record }[] { + return this.routes.map((route) => { + const view = route.view; + const params = Object.fromEntries(pathSegments(route.path).filter((part) => part.startsWith(":")).map((part) => { + const key = part.slice(1).replace(/[?*]$/, ""); + return [key, route.options.params?.[key] ?? view?.params?.[key] ?? ""]; + })); + return { name: route.options.name ?? view?.name ?? route.methodName, description: route.options.description ?? view?.description, path: route.path, method: route.method, params }; + }); + } + + /** Finds the route matching a method and path. */ + match(method: string, path: string): RouteMatch { + const segments = pathSegments(path); + const allowed = new Set(); + for (const route of this.routes) { + const params = matchSegments(pathSegments(route.path), segments); + if (!params) continue; + if (route.method === method) { + return { kind: "match", route, params }; + } + allowed.add(route.method); + } + if (allowed.size > 0) { + return { kind: "method-not-allowed", allowed: [...allowed] }; + } + return { kind: "not-found" }; + } + + /** Executes a request and normalises the handler result into a response. */ + async handle(context: RequestContext): Promise { + for (const route of this.rawRoutes) { + if (route.method !== context.method) continue; + if (route.path.endsWith("*") ? !context.path.startsWith(route.path.slice(0, -1)) : route.path !== context.path) continue; + const response = await route.handler( + context.raw as Request, + context, + ); + return { + status: response.status as StatusCode, + headers: Object.fromEntries(response.headers.entries()), + body: new Uint8Array(await response.arrayBuffer()), + }; + } + + const match = this.match(context.method, context.path); + if (match.kind === "not-found") { + const asset = await this.serveStatic(context.path); + if (asset) return asset; + return { + status: 404, + headers: { "content-type": "text/plain; charset=utf-8" }, + body: "Not Found", + }; + } + if (match.kind === "method-not-allowed") { + return { + status: 405, + headers: { + "content-type": "text/plain; charset=utf-8", + allow: match.allowed.join(", "), + }, + body: "Method Not Allowed", + }; + } + + context.params = match.params; + context.locals.routes = this.clientRoutes(); + const blocked = await this.options.pipeline?.run(context, match.route); + if (blocked) return blocked; + const auth = getAuth(match.route.controller, match.route.methodName); + if (auth) { + const identity = context.locals.auth as { roles?: string[] } | undefined; + if (!identity || (auth.roles?.some((role) => !identity.roles?.includes(role)) ?? false)) { + return { status: auth.redirect ? 302 : identity ? 403 : 401, headers: auth.redirect ? { location: auth.redirect } : { "content-type": "text/plain; charset=utf-8" }, body: auth.redirect ? undefined : "Unauthorized" }; + } + } + const result = await match.route.invoke(context); + return this.toResponse(result, context, match.route); + } + + /** + * Serves a file of the static root of a controller. + */ + private async serveStatic(path: string): Promise { + const candidates = this.staticRoots + .filter((root) => path.startsWith(root.prefix)) + .sort((left, right) => right.prefix.length - left.prefix.length); + + for (const candidate of candidates) { + const relative = path.slice(candidate.prefix.length) || "/"; + const file = await serveStaticFile(candidate.path, relative); + if (file) return file; + } + return undefined; + } + + private async invokeHandler( + instance: unknown, + methodName: string, + paramMetadata: Map, + context: RequestContext, + ): Promise { + const handler = (instance as Record)[methodName]; + if (typeof handler !== "function") { + throw new Error(`Controller method ${methodName} is not callable`); + } + + if (paramMetadata.size === 0) { + return (handler as (ctx: RequestContext) => unknown).call( + instance, + context, + ); + } + + const args: unknown[] = []; + for (const [index, meta] of paramMetadata) { + args[index] = resolveParam(meta, context); + } + return (handler as (...args: unknown[]) => unknown).apply(instance, args); + } + + private async toResponse( + result: unknown, + context: RequestContext, + route: RouteDefinition, + ): Promise { + const status = route.options.status ?? 200; + + if (route.view) { + const descriptor: ViewDescriptor = isViewDescriptor(result) + ? result + : { + template: route.view.path, + props: (result ?? {}) as Record, + }; + const rendered = await this.renderView(descriptor, context); + return { + status: descriptor.status ?? status, + headers: { + "content-type": "text/html; charset=utf-8", + ...(descriptor.headers ?? {}), + }, + body: rendered, + }; + } + + if (result === undefined || result === null) { + return { status, headers: {}, body: result ?? undefined }; + } + + if (isHttpResponse(result)) { + return { + status: result.status, + headers: result.headers ?? {}, + body: result.body, + }; + } + + if (isViewDescriptor(result)) { + const rendered = await this.renderView(result, context); + return { + status: result.status ?? status, + headers: { + "content-type": "text/html; charset=utf-8", + ...(result.headers ?? {}), + }, + body: rendered, + }; + } + + if (typeof result === "string") { + return { + status, + headers: { "content-type": "text/html; charset=utf-8" }, + body: result, + }; + } + + return { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + body: result, + }; + } + + private async renderView( + descriptor: ViewDescriptor, + context: RequestContext, + ): Promise> { + const renderer = this.options.renderView; + if (!renderer) { + throw new Error( + `Template "${descriptor.template}" cannot be rendered: no view renderer configured`, + ); + } + const rendered = await renderer(descriptor, context); + if (typeof rendered === "string") return rendered; + if (rendered.deferred && rendered.deferred.length > 0) { + return toStream(rendered); + } + return rendered.html; + } +} + +/** Builds a stream that sends the shell first and the deferred chunks after. */ +export function toStream(rendered: RenderedView): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + async start(controller) { + controller.enqueue(encoder.encode(rendered.html)); + for (const chunk of rendered.deferred ?? []) { + const content = await chunk.content; + controller.enqueue( + encoder.encode( + `

${content}
`, + ), + ); + } + controller.close(); + }, + }); +} + +function resolveParam(meta: ParamMetadata, context: RequestContext): unknown { + switch (meta.kind) { + case "param": + return meta.name + ? (context.params as Record)[meta.name] + : context.params; + case "body": + return context.body; + case "form": + return context.form; + case "query": + return meta.name ? context.query[meta.name] : context.query; + case "request": + return context; + case "response": + return context.respond; + case "locals": + return context.locals; + } +} + +function isHttpResponse(value: unknown): value is HttpResponse { + return ( + typeof value === "object" && + value !== null && + typeof (value as HttpResponse).status === "number" && + "headers" in value + ); +} + +function isViewDescriptor(value: unknown): value is ViewDescriptor { + return ( + typeof value === "object" && + value !== null && + typeof (value as ViewDescriptor).template === "string" + ); +} diff --git a/core/sessions.ts b/core/sessions.ts new file mode 100644 index 0000000..128cab3 --- /dev/null +++ b/core/sessions.ts @@ -0,0 +1,28 @@ +export interface SessionRecord { + value: Record; + expires: number; +} + +export interface SessionStore { + get(key: string): Promise; + set(key: string, record: SessionRecord): Promise; + delete(key: string): Promise; + /** Atomically removes and returns a transaction, preventing callback replay. */ + consume(key: string): Promise; +} + +export class MemorySessionStore implements SessionStore { + private readonly records = new Map(); + async get(key: string): Promise { + const record = this.records.get(key); + if (!record || record.expires <= Date.now()) { this.records.delete(key); return undefined; } + return structuredClone(record); + } + async set(key: string, record: SessionRecord): Promise { this.records.set(key, structuredClone(record)); } + async delete(key: string): Promise { this.records.delete(key); } + async consume(key: string): Promise { + const record = this.records.get(key); + this.records.delete(key); + return record && record.expires > Date.now() ? structuredClone(record) : undefined; + } +} diff --git a/core/templates.ts b/core/templates.ts new file mode 100644 index 0000000..32d5f56 --- /dev/null +++ b/core/templates.ts @@ -0,0 +1,62 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +export type MuddleTemplate = "docs" | "minimal"; + +export interface TemplateResult { + name: MuddleTemplate; + output: string; + files: string[]; +} + +const sharedFiles: Record = { + "package.json": `${JSON.stringify({ + name: "my-muddle-site", + private: true, + type: "module", + scripts: { dev: "muddle dev .", build: "muddle build ." }, + dependencies: { muddle: "latest" }, + }, null, 2)}\n`, + "tsconfig.json": `${JSON.stringify({ + compilerOptions: { target: "ESNext", module: "Preserve", moduleResolution: "bundler", strict: true, noEmit: true, experimentalDecorators: true, allowImportingTsExtensions: true }, + }, null, 2)}\n`, + "config.ts": `import { resolveConfig } from "muddle";\nimport config from "./ct.config.ts";\n\nexport const resolved = resolveConfig(config);\n`, + "modules/app/app.module.ts": `import { Module } from "muddle";\nimport { AppController } from "./app.controller.ts";\n\n@Module({ controllers: [AppController] })\nexport class AppModule {}\n`, +}; + +const minimalFiles: Record = { + "ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist" },\n});\n`, + "index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist") });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle demo on \${serve(app, { port: 3000 }).url}\`);\n}\n`, + "modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() {\n\t\treturn { title: "Muddle", message: "A small, fast Muddle project." };\n\t}\n}\n`, + "modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n main.shell\n a.mark(href="/") muddle\n section.hero@demo.hero()\n p.kicker Vite-like starter\n h1= message\n p Edit the Spug view and STCSS stylesheet, then run muddle dev ..\n a.action(href="#next") Open the canvas\n section#next.card@demo.next()\n h2 Start with one route.\n p Build a static site with ordinary HTML, CSS, and server-side composition.\n`, + "styles/app.stcss": `:root { --ink: #1f2937; --muted: #64748b; --surface: #ffffff; --accent: #646cff; }\n* { box-sizing: border-box; }\nbody { margin: 0; color: var(--ink); background: #f8fafc; font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.shell { width: min(100% - 2rem, 68rem); margin: 0 auto; padding: 1.5rem 0 5rem; }\n.mark { color: var(--ink); font-weight: 800; letter-spacing: -0.06em; text-decoration: none; }\n.hero { max-width: 48rem; padding: 8rem 0 5rem; }\n.kicker { margin: 0; color: var(--accent); font-size: .78rem; font-weight: 800; letter-spacing: .12em; text-transform: uppercase; }\nh1 { margin: .7rem 0 1rem; font-size: clamp(3.5rem, 9vw, 7.25rem); line-height: .94; letter-spacing: -.075em; }\n.hero p { max-width: 34rem; color: var(--muted); font-size: 1.1rem; line-height: 1.65; }\n.action { display: inline-block; margin-top: 1rem; padding: .8rem 1rem; border-radius: .55rem; background: var(--accent); color: white; font-weight: 700; text-decoration: none; }\n.card { max-width: 38rem; padding: 1.5rem; border: 1px solid #e2e8f0; border-radius: 1rem; background: var(--surface); }\n.card h2 { margin: 0; letter-spacing: -.04em; }\n.card p { color: var(--muted); line-height: 1.6; }\n`, + "README.md": `# Minimal Muddle\n\nRun \`bun install\`, then \`bun run dev\`. Build the static output with \`bun run build\`.\n`, +}; + +const docsFiles: Record = { + "ct.config.ts": `import { defineConfig } from "muddle";\nimport { stcssIntegration } from "muddle/views/integrations";\n\nexport default defineConfig({\n\troot: import.meta.dir,\n\tviews: "modules/app/views",\n\tviewRoots: ["modules/app/views"],\n\tstyles: "styles",\n\tintegrations: [stcssIntegration({ dir: "styles" })],\n\tprerender: { routes: ["/"], outDir: "dist", predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } },\n});\n`, + "index.ts": `import { join } from "node:path";\nimport { App, createViews, prerender, serve } from "muddle";\nimport { AppModule } from "./modules/app/app.module.ts";\nimport { resolved } from "./config.ts";\n\nconst views = createViews({ root: resolved.root, viewsDir: resolved.views, componentsDir: resolved.components });\nexport const app = await App.create({ modules: [AppModule], views, config: resolved, integrations: resolved.integrations });\n\nexport async function build() {\n\tawait prerender(app, { ...resolved.prerender, outDir: join(resolved.root, "dist"), predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] } });\n}\n\nif (import.meta.main) {\n\tif (process.argv.includes("--prerender")) await build();\n\telse console.log(\`muddle docs on \${serve(app, { port: 3000 }).url}\`);\n}\n`, + "modules/app/app.controller.ts": `import { Controller, View, ViewRoot } from "muddle";\n\nconst sections = [\n\t{ id: "overview", title: "Overview", body: "Muddle composes static pages from Spug and STCSS." },\n\t{ id: "predict", title: "Reflective prediction", body: "Dotted anchors form groups, so the renderer discovers matching elements before it writes the page." },\n\t{ id: "deploy", title: "Deploy", body: "The finished bundle is HTML and CSS with precomputed responsive relations." },\n];\n\n@Controller("/")\n@ViewRoot("./views")\nexport class AppController {\n\t@View("index.spug")\n\tindex() { return { title: "Muddle Docs", sections }; }\n}\n`, + "modules/app/views/index.spug": `doctype html\nhtml(lang="en")\n head\n meta(charset="UTF-8")\n meta(name="viewport" content="width=device-width, initial-scale=1")\n title= title\n link(rel="stylesheet" href="/_muddle/styles.css")\n body\n header.topbar\n a.brand(href="/") muddle\n span.topbar__tag Documentation\n div.docs\n aside.sidebar\n p.sidebar__title Guide\n nav.sidebar__nav(aria-label="Documentation")\n each section in sections\n a.sidebar__link@docs.{section.id}(href='#' + section.id)= section.title\n main.content\n p.eyebrow Muddle documentation\n h1 Static interfaces, with measured relationships.\n p.lede This template uses responsive layout prediction and reflective anchor groups. Resize it, then follow the highlighted section.\n each section in sections\n section.doc-section@docs.{section.id}(id=section.id)\n h2= section.title\n p= section.body\n if section.id === "predict"\n pre\n code .sidebar:reflect(@docs) { transition: background 180ms ease; }\n`, + "styles/app.stcss": `:root { --ink: #20212a; --muted: #6d6f7c; --line: #e7e7ec; --paper: #fff; --wash: #f7f7fb; --accent: #7656df; }\n* { box-sizing: border-box; }\nhtml { scroll-behavior: smooth; }\nbody { margin: 0; color: var(--ink); background: var(--paper); font-family: Inter, ui-sans-serif, system-ui, sans-serif; }\n.topbar { position: sticky; z-index: 2; top: 0; display: flex; justify-content: space-between; align-items: center; height: 3.75rem; padding: 0 max(1rem, calc((100% - 78rem) / 2)); border-bottom: 1px solid var(--line); background: color-mix(in srgb, var(--paper) 88%, transparent); backdrop-filter: blur(14px); }\n.brand { color: var(--ink); font-weight: 800; letter-spacing: -.055em; text-decoration: none; }\n.topbar__tag, .sidebar__title, .eyebrow { color: var(--muted); font-size: .72rem; font-weight: 760; letter-spacing: .1em; text-transform: uppercase; }\n.docs { display: grid; grid-template-columns: 15rem minmax(0, 48rem); gap: clamp(2rem, 7vw, 7rem); width: min(100% - 2rem, 78rem); margin: 0 auto; }\n.sidebar { position: sticky; top: 5.5rem; align-self: start; padding: 2rem 0; }\n.sidebar__title { margin: 0 0 .65rem; }\n.sidebar__nav { display: grid; gap: .18rem; }\n.sidebar__link { position: relative; display: block; overflow: hidden; padding: .55rem .7rem; border-radius: .45rem; color: var(--muted); text-decoration: none; transition: color 180ms ease, transform 180ms ease; }\n.sidebar__link::before { position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: #ede8ff; content: ""; transform: scaleX(0); transform-origin: left; transition: transform 220ms cubic-bezier(.2,.8,.2,1); }\n.sidebar__link:hover, .sidebar__link:focus-visible { color: var(--accent); transform: translateX(.18rem); }\n.sidebar__link:hover::before, .sidebar__link:focus-visible::before { transform: scaleX(1); }\n.sidebar__link:target, .sidebar__link:target::before { color: var(--accent); transform: scaleX(1); }\n:reflect(@docs) { scroll-margin-top: 5.5rem; }\n.content { padding: clamp(3rem, 10vw, 7rem) 0 8rem; }\nh1 { max-width: 42rem; margin: .7rem 0 1.2rem; font-size: clamp(3rem, 6vw, 5.8rem); line-height: .95; letter-spacing: -.075em; }\n.lede { max-width: 42rem; color: var(--muted); font-size: 1.12rem; line-height: 1.7; }\n.doc-section { padding: 5rem 0; border-bottom: 1px solid var(--line); }\n.doc-section h2 { margin: 0 0 .8rem; font-size: 1.7rem; letter-spacing: -.045em; }\n.doc-section p { max-width: 38rem; color: var(--muted); line-height: 1.7; }\npre { overflow: auto; padding: 1rem; border: 1px solid var(--line); border-radius: .7rem; background: var(--wash); }\n@media (max-width: 48rem) { .docs { display: block; } .sidebar { position: sticky; top: 3.75rem; z-index: 1; display: block; margin: 0 -1rem; padding: .65rem 1rem; border-bottom: 1px solid var(--line); background: var(--paper); } .sidebar__title { display: none; } .sidebar__nav { display: flex; overflow: auto; } .sidebar__link { white-space: nowrap; } .content { padding-top: 3rem; } }\n`, + "README.md": `# Muddle Docs\n\nRun \`bun install\`, then \`bun run dev\`. The docs shell has a responsive sidebar and uses the \`docs\` reflective anchor group. Build it with \`bun run build\`.\n`, +}; + +export const muddleTemplates: readonly MuddleTemplate[] = ["docs", "minimal"]; + +export async function createTemplate(name: MuddleTemplate, destination: string): Promise { + const output = resolve(destination); + if (existsSync(output) && (await readdir(output)).length > 0) { + throw new Error(`Refusing to overwrite non-empty directory ${output}`); + } + const files = { ...sharedFiles, ...(name === "docs" ? docsFiles : minimalFiles) }; + const written: string[] = []; + for (const [relative, source] of Object.entries(files)) { + const file = join(output, relative); + await mkdir(join(file, ".."), { recursive: true }); + await writeFile(file, source); + written.push(file); + } + return { name, output, files: written }; +} diff --git a/ct.config.ts b/ct.config.ts new file mode 100644 index 0000000..ce33fb3 --- /dev/null +++ b/ct.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "./contracts"; +import { stcssIntegration } from "./views/integrations.ts"; + +export default defineConfig({ + root: import.meta.dir, + entry: "samples/hello-world/index.ts", + + views: "samples/hello-world/modules/app/views", + components: "samples/hello-world/modules/app/views/components", + viewRoots: ["samples/hello-world/modules/app/views"], + + integrations: [ + stcssIntegration({ dir: "samples/hello-world/styles" }), + ], + + prerender: { + routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"], + outDir: "samples/hello-world/dist", + }, + +}); diff --git a/decorators/Astro.ts b/decorators/Astro.ts new file mode 100644 index 0000000..5d58f25 --- /dev/null +++ b/decorators/Astro.ts @@ -0,0 +1,8 @@ +import { Route, type RouteOptions } from "./Route.ts"; + +export interface AstroOptions extends RouteOptions { partial?: boolean; action?: boolean } + +/** Declares an Astro-compatible endpoint. */ +export function Astro(path: string, options: AstroOptions = {}): MethodDecorator { + return Route(path, { ...options, method: options.action ? "POST" : options.method ?? "GET" }); +} diff --git a/decorators/Auth.ts b/decorators/Auth.ts new file mode 100644 index 0000000..5ac178f --- /dev/null +++ b/decorators/Auth.ts @@ -0,0 +1,12 @@ +import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts"; + +const KEY = "muddle:auth"; +export interface AuthOptions { roles?: string[]; redirect?: string } + +export function Auth(options: AuthOptions = {}): MethodDecorator { + return (target, propertyKey) => defineMemberMetadata(KEY, options, target as object, propertyKey as string); +} + +export function getAuth(target: object, propertyKey: string): AuthOptions | undefined { + return getMemberMetadata(KEY, target, propertyKey)[0] as AuthOptions | undefined; +} diff --git a/decorators/Component.ts b/decorators/Component.ts new file mode 100644 index 0000000..a7d1717 --- /dev/null +++ b/decorators/Component.ts @@ -0,0 +1,51 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; + +export const COMPONENT_METADATA_KEY = "muddle:component"; + +export interface ComponentOptions { + /** Name used inside the templates, defaults to the class name. */ + name?: string; + /** + * Renders the component after the page, the response is streamed. + */ + deferred?: boolean; + description?: string; + params?: Record; +} + +/** Runtime description of a server component. */ +export interface ComponentMetadata { + name: string; + deferred: boolean; + description?: string; + params?: Record; +} + +/** Declares a server component. */ +export function Component(options: ComponentOptions = {}): ClassDecorator { + return (target) => { + const metadata: ComponentMetadata = { + name: options.name ?? target.name, + deferred: options.deferred === true, + description: options.description, + params: options.params, + }; + defineMetadata(COMPONENT_METADATA_KEY, metadata, target as object); + return target; + }; +} + +/** Reads the metadata of a server component. */ +export function getComponentMetadata( + target: object, +): ComponentMetadata | undefined { + return getMetadata(COMPONENT_METADATA_KEY, target) as + | ComponentMetadata + | undefined; +} + +/** `true` when the class is a server component. */ +export function isComponent(target: object): boolean { + return getComponentMetadata(target) !== undefined; +} diff --git a/decorators/Controller.ts b/decorators/Controller.ts new file mode 100644 index 0000000..6e0948d --- /dev/null +++ b/decorators/Controller.ts @@ -0,0 +1,59 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import { callerFile } from "../core/paths.ts"; +import { dirname } from "node:path"; + +/** + * `@Controller()` groups HTTP handlers under a path prefix. + */ +export const CONTROLLER_METADATA_KEY = "muddle:controller"; + +export interface ControllerOptions { + /** Path prefix of every route of the controller, e.g. `/users`. */ + prefix?: string; + /** Optional tag used by documentation adapters. */ + tag?: string; + /** Directory of the file that declares the controller. */ + dir?: string; +} + +/** Normalises a prefix to `/segment` shape (no trailing slash). */ +export function normalizePrefix(prefix: string): string { + if (!prefix || prefix === "/") return ""; + const withLeading = prefix.startsWith("/") ? prefix : `/${prefix}`; + return withLeading.replace(/\/+$/, ""); +} + +/** Declares a controller. */ +export function Controller( + prefix?: string | ControllerOptions, +): ClassDecorator { + return (target) => { + const options: ControllerOptions = + typeof prefix === "string" ? { prefix } : (prefix ?? {}); + const caller = callerFile(); + defineMetadata( + CONTROLLER_METADATA_KEY, + { + ...options, + dir: options.dir ?? (caller ? dirname(caller) : undefined), + }, + target as object, + ); + return target; + }; +} + +/** Reads the options of a `@Controller()` decorated class. */ +export function getControllerOptions(target: object): ControllerOptions { + return ( + (getMetadata(CONTROLLER_METADATA_KEY, target) as + | ControllerOptions + | undefined) ?? {} + ); +} + +/** Reads the normalised path prefix of a controller. */ +export function getControllerPrefix(target: object): string { + return normalizePrefix(getControllerOptions(target).prefix ?? ""); +} diff --git a/decorators/Inject.ts b/decorators/Inject.ts new file mode 100644 index 0000000..df8ae9d --- /dev/null +++ b/decorators/Inject.ts @@ -0,0 +1,35 @@ +import "../core/metadata.ts"; +import { defineMemberMetadata, getLatestMetadata } from "../core/metadata.ts"; +import type { Token } from "../core/container.ts"; +import { + INJECT_TOKENS_METADATA, + OPTIONAL_PARAMS_METADATA, +} from "../core/container.ts"; + +/** + * `@Inject()` overrides the injection token of a constructor parameter. + */ +export function Inject(token: Token): ParameterDecorator { + return (target, _propertyKey, index) => { + const entries = + (getLatestMetadata(INJECT_TOKENS_METADATA, target) as + | { index: number; token: Token }[] + | undefined) ?? []; + entries.push({ index, token }); + defineMemberMetadata(INJECT_TOKENS_METADATA, entries, target as object); + }; +} + +/** + * `@Optional()` allows a dependency to be missing. + */ +export function Optional(): ParameterDecorator { + return (target, _propertyKey, index) => { + const entries = + (getLatestMetadata(OPTIONAL_PARAMS_METADATA, target) as + | number[] + | undefined) ?? []; + entries.push(index); + defineMemberMetadata(OPTIONAL_PARAMS_METADATA, entries, target as object); + }; +} diff --git a/decorators/Module.ts b/decorators/Module.ts new file mode 100644 index 0000000..9e22b3b --- /dev/null +++ b/decorators/Module.ts @@ -0,0 +1,69 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import type { + AbstractType, + ProviderScope, + Token, + Type, +} from "../core/container.ts"; + +export const MODULE_METADATA_KEY = "muddle:module"; + +export interface ModuleOptions { + /** Controllers instantiated by this module. */ + controllers?: Type[]; + /** Providers available inside this module. */ + providers?: (Type | AbstractType | ProviderDefinitionLike)[]; + /** Modules whose exported providers are visible here. */ + imports?: Type[]; + /** Tokens (or modules) re-exported to importers of this module. */ + exports?: Token[]; +} + +export interface ProviderDefinitionLike { + provide?: Token; + useValue?: unknown; + useClass?: AbstractType; + useFactory?: (...dependencies: unknown[]) => unknown; + inject?: Token[]; + scope?: ProviderScope; +} + +/** Marks a class as injectable and therefore eligible for type based injection. */ +export const INJECTABLE_METADATA_KEY = "muddle:injectable"; + +export function Injectable( + options: { scope?: ProviderScope } = {}, +): ClassDecorator { + return (target) => { + defineMetadata(INJECTABLE_METADATA_KEY, options, target as object); + return target; + }; +} + +/** Declares a module. */ +export function Module(options: ModuleOptions): ClassDecorator { + return (target) => { + defineMetadata(MODULE_METADATA_KEY, options, target as object); + return target; + }; +} + +/** Reads the options of a `@Module()` decorated class. */ +export function getModuleOptions(target: object): ModuleOptions { + return ( + (getMetadata(MODULE_METADATA_KEY, target) as ModuleOptions | undefined) ?? + {} + ); +} + +/** Reads the options of an `@Injectable()` decorated class. */ +export function getInjectableOptions(target: object): { + scope?: ProviderScope; +} { + return ( + (getMetadata(INJECTABLE_METADATA_KEY, target) as + | { scope?: ProviderScope } + | undefined) ?? {} + ); +} diff --git a/decorators/Params.ts b/decorators/Params.ts new file mode 100644 index 0000000..0f08788 --- /dev/null +++ b/decorators/Params.ts @@ -0,0 +1,125 @@ +import "../core/metadata.ts"; +import { defineMemberMetadata, getMemberMetadata } from "../core/metadata.ts"; + +export const PARAM_METADATA_KEY = "muddle:params"; + +export type ParamKind = + | "param" + | "body" + | "query" + | "request" + | "response" + | "locals" + | "form"; + +export interface ParamMetadata { + index: number; + kind: ParamKind; + /** Name of the route parameter or query key. */ + name?: string; +} + +/** `@Param('id')`, a route parameter. */ +export function Param(name?: string): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "param", name } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Body()`, the parsed request body. */ +export function Body(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "body" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Form()`, the submitted form fields. */ +export function Form(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "form" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Query('page')`, a query string entry. */ +export function Query(name?: string): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "query", name } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Req()`, the full request context. */ +export function Req(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "request" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Res()`, the response builder. */ +export function Res(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "response" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** `@Locals()`, the mutable per request scratch space. */ +export function Locals(): ParameterDecorator { + return (target, _propertyKey, index) => { + defineMemberMetadata( + PARAM_METADATA_KEY, + { index, kind: "locals" } satisfies ParamMetadata, + target as object, + _propertyKey as string, + ); + }; +} + +/** + * Reads the parameter descriptors of a controller method. + */ +export function getParamMetadata( + target: object, + propertyKey?: string, +): Map { + const entries = propertyKey + ? (getMemberMetadata( + PARAM_METADATA_KEY, + target, + propertyKey, + ) as ParamMetadata[]) + : []; + const map = new Map(); + for (const entry of entries) { + map.set(entry.index, entry); + } + return map; +} diff --git a/decorators/RenderAhead.ts b/decorators/RenderAhead.ts new file mode 100644 index 0000000..1fea63f --- /dev/null +++ b/decorators/RenderAhead.ts @@ -0,0 +1,15 @@ +import { defineMetadata, getMetadata } from "../core/metadata.ts"; + +const KEY = "muddle:render-ahead"; + +export type RenderAheadSource = string[] | (() => Promise | string[]); + +export function RenderAhead(routes: RenderAheadSource): MethodDecorator { + return (target, propertyKey) => { + defineMetadata(KEY, routes, target, propertyKey); + }; +} + +export function getRenderAhead(target: object, propertyKey: string): RenderAheadSource | undefined { + return getMetadata(KEY, target, propertyKey) as RenderAheadSource | undefined; +} diff --git a/decorators/RenderRoute.ts b/decorators/RenderRoute.ts new file mode 100644 index 0000000..2c47574 --- /dev/null +++ b/decorators/RenderRoute.ts @@ -0,0 +1,26 @@ +import { defineMetadata, getMetadata } from "../core/metadata.ts"; + +const KEY = "muddle:render-route"; + +export interface CollectionRenderRoute { + collection: string; + path: string; +} + +export type RenderRouteSource = string | string[] | CollectionRenderRoute; + +export function routeFolder(...segments: string[]): string { + return `/${segments.filter(Boolean).map((segment) => segment.replace(/^\/+|\/+$/g, "")).join("/")}`; +} + +export function collectionRoute(collection: string, path: string): CollectionRenderRoute { + return { collection, path }; +} + +export function RenderRoute(routes: RenderRouteSource[]): MethodDecorator { + return (target, propertyKey) => defineMetadata(KEY, routes, target, propertyKey); +} + +export function getRenderRoutes(target: object, propertyKey: string): RenderRouteSource[] { + return (getMetadata(KEY, target, propertyKey) as RenderRouteSource[] | undefined) ?? []; +} diff --git a/decorators/Root.ts b/decorators/Root.ts new file mode 100644 index 0000000..f5200c6 --- /dev/null +++ b/decorators/Root.ts @@ -0,0 +1,75 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import { callerFile } from "../core/paths.ts"; +import { existsSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +export const ROOT_METADATA_KEY = "muddle:root"; + +export interface RootMetadata { + /** Absolute path of the directory. */ + path: string; +} + +/** Declares the static root of a controller. */ +export function Root(path: string): ClassDecorator { + return (target) => { + const caller = callerFile(); + const base = caller ? dirname(caller) : process.cwd(); + const absolute = isAbsolute(path) ? path : resolve(base, path); + defineMetadata( + ROOT_METADATA_KEY, + { path: absolute } satisfies RootMetadata, + target as object, + ); + return target; + }; +} + +/** Reads the static root of a controller. */ +export function getRoot(target: object): RootMetadata | undefined { + return getMetadata(ROOT_METADATA_KEY, target) as RootMetadata | undefined; +} + +export const VIEW_ROOT_METADATA_KEY = "muddle:view-root"; + +export interface ViewRootMetadata { + /** Absolute path of the folder. */ + path: string; +} + +/** Declares the view folder of a controller. */ +export function ViewRoot(path: string): ClassDecorator { + return (target) => { + const caller = callerFile(); + const base = caller ? dirname(caller) : process.cwd(); + const absolute = isAbsolute(path) ? path : resolve(base, path); + defineMetadata( + VIEW_ROOT_METADATA_KEY, + { path: absolute } satisfies ViewRootMetadata, + target as object, + ); + return target; + }; +} + +/** Reads the view folder of a controller. */ +export function getViewRoot(target: object): ViewRootMetadata | undefined { + return getMetadata(VIEW_ROOT_METADATA_KEY, target) as + | ViewRootMetadata + | undefined; +} + +/** + * Resolves the folder of the templates of a controller. + */ +export function resolveViewRoot(target: object, fallback: string): string { + const root = getViewRoot(target); + if (root) return root.path; + return fallback; +} + +/** `true` when the directory exists. */ +export function isDirectory(path: string): boolean { + return existsSync(path) && existsSync(join(path, ".")); +} diff --git a/decorators/Route.ts b/decorators/Route.ts new file mode 100644 index 0000000..b866551 --- /dev/null +++ b/decorators/Route.ts @@ -0,0 +1,80 @@ +import "../core/metadata.ts"; +import { + defineMemberMetadata, + getAllMemberMetadata, +} from "../core/metadata.ts"; +import type { HttpMethod } from "../contracts/typegen/http/method.ts"; +import { normalizeMethod } from "../contracts/typegen/http/method.ts"; +import type { StatusCode } from "../contracts/typegen/http/respcode.ts"; + +/** + * `@Route()` binds a controller method to an HTTP route. + */ +export const ROUTE_METADATA_KEY = "muddle:route"; + +export interface RouteOptions { + /** HTTP method(s) handled by the route, defaults to `GET`. */ + method?: HttpMethod | HttpMethod[]; + /** Status code used when the handler returns a plain value. */ + status?: StatusCode; + /** Route name, used by the fluent view helpers. */ + name?: string; + /** Human-readable route description. */ + description?: string; + /** Parameter description by parameter name. */ + params?: Record; +} + +export interface RouteMetadata { + /** Path relative to the controller prefix. */ + path: string; + method: HttpMethod; + options: RouteOptions; + /** Name of the decorated method. */ + propertyKey: string; +} + +function normalizeMethods(method: RouteOptions["method"]): HttpMethod[] { + if (method === undefined) return ["GET"]; + const list = Array.isArray(method) ? method : [method]; + return list.map((entry) => normalizeMethod(entry)); +} + +/** Declares one or more routes for a controller method. */ +export function Route( + path: string, + options: RouteOptions = {}, +): MethodDecorator { + return (target, propertyKey, descriptor) => { + for (const httpMethod of normalizeMethods(options.method)) { + defineMemberMetadata( + ROUTE_METADATA_KEY, + { + path, + method: httpMethod, + options, + propertyKey: String(propertyKey), + } satisfies RouteMetadata, + target as object, + propertyKey as string, + ); + } + return descriptor; + }; +} + +/** + * Reads every route declared on a controller class. + * + * When a property key is given, only the routes of that method are returned. + */ +export function getRoutes( + target: object, + propertyKey?: string, +): RouteMetadata[] { + return getAllMemberMetadata(ROUTE_METADATA_KEY, target) + .filter( + (entry) => propertyKey === undefined || entry.propertyKey === propertyKey, + ) + .map((entry) => entry.value as RouteMetadata); +} diff --git a/decorators/Service.ts b/decorators/Service.ts new file mode 100644 index 0000000..c83675b --- /dev/null +++ b/decorators/Service.ts @@ -0,0 +1,26 @@ +import "../core/metadata.ts"; +import { defineMetadata, getMetadata } from "../core/metadata.ts"; +import type { ProviderScope } from "../core/container.ts"; + +export const SERVICE_METADATA_KEY = "muddle:service"; + +export interface ServiceOptions { + /** Lifetime of the service. */ + scope?: ProviderScope; +} + +/** Declares a service. */ +export function Service(options: ServiceOptions = {}): ClassDecorator { + return (target) => { + defineMetadata(SERVICE_METADATA_KEY, options, target as object); + return target; + }; +} + +/** Reads the options of a `@Service()` decorated class. */ +export function getServiceOptions(target: object): ServiceOptions { + return ( + (getMetadata(SERVICE_METADATA_KEY, target) as ServiceOptions | undefined) ?? + {} + ); +} diff --git a/decorators/View.ts b/decorators/View.ts new file mode 100644 index 0000000..a413e3b --- /dev/null +++ b/decorators/View.ts @@ -0,0 +1,83 @@ +import "../core/metadata.ts"; +import { defineMemberMetadata, getAllMemberMetadata } from "../core/metadata.ts"; +import { callerFile } from "../core/paths.ts"; +import { dirname } from "node:path"; +import type { HttpMethod } from "../contracts/typegen/http/method.ts"; +import { normalizeMethod } from "../contracts/typegen/http/method.ts"; +import type { StatusCode } from "../contracts/typegen/http/respcode.ts"; + +export const VIEW_METADATA_KEY = "muddle:view"; + +export interface ViewOptions { + /** HTTP method of the route, defaults to `GET`. */ + method?: HttpMethod | HttpMethod[]; + /** Route path, derived from the template when omitted. */ + path?: string; + /** Status code of the response. */ + status?: StatusCode; + name?: string; + description?: string; + params?: Record; +} + +export interface ViewMetadata { + /** Name of the method the view belongs to. */ + propertyKey: string; + /** Path of the template, relative to the view folder. */ + path: string; + /** Directory of the file that declared the view. */ + dir: string; + method: HttpMethod; + status?: StatusCode; + name?: string; + description?: string; + params?: Record; +} + +/** Declares the template rendered by a controller method. */ +export function View(path?: string, options: ViewOptions = {}): MethodDecorator { + return (target, propertyKey, descriptor) => { + const caller = callerFile(); + const method = String(propertyKey); + const methods = options.method + ? Array.isArray(options.method) + ? options.method + : [options.method] + : ["GET" as HttpMethod]; + + for (const httpMethod of methods) { + defineMemberMetadata( + VIEW_METADATA_KEY, + { + propertyKey: method, + path: path ?? `./${method}.spug`, + dir: caller ? dirname(caller) : process.cwd(), + method: normalizeMethod(httpMethod), + status: options.status, + name: options.name, + description: options.description, + params: options.params, + } satisfies ViewMetadata, + target as object, + propertyKey as string, + ); + } + return descriptor; + }; +} + +/** + * Reads the view metadata of a controller class. + * + * When a property key is given, only the views of that method are returned. + */ +export function getViews( + target: object, + propertyKey?: string, +): ViewMetadata[] { + return getAllMemberMetadata(VIEW_METADATA_KEY, target) + .filter( + (entry) => propertyKey === undefined || entry.propertyKey === propertyKey, + ) + .map((entry) => entry.value as ViewMetadata); +} diff --git a/decorators/index.ts b/decorators/index.ts new file mode 100644 index 0000000..db3792e --- /dev/null +++ b/decorators/index.ts @@ -0,0 +1,13 @@ +export * from "./Component.ts"; +export * from "./Controller.ts"; +export * from "./Inject.ts"; +export * from "./Module.ts"; +export * from "./Params.ts"; +export * from "./Root.ts"; +export * from "./Route.ts"; +export * from "./Service.ts"; +export * from "./View.ts"; +export * from "./RenderAhead.ts"; +export * from "./RenderRoute.ts"; +export * from "./Astro.ts"; +export * from "./Auth.ts"; diff --git a/editors/vscode/.vscodeignore b/editors/vscode/.vscodeignore new file mode 100644 index 0000000..f98339b --- /dev/null +++ b/editors/vscode/.vscodeignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock \ No newline at end of file diff --git a/editors/vscode/bun.lock b/editors/vscode/bun.lock new file mode 100644 index 0000000..23a1e7a --- /dev/null +++ b/editors/vscode/bun.lock @@ -0,0 +1,285 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "muddle-language-tools", + "dependencies": { + "@vscode/vsce": "^4.0.0", + }, + }, + }, + "packages": { + "@azure/abort-controller": ["@azure/abort-controller@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-fNAjWnA/nZ2jz31kxR/AqRaUT8ewHBw/WuBIosK0moMy1C9e5ValbDfFdIxJzVOOYaYkV/b2F1S4H/aHiqfVQg=="], + + "@azure/core-auth": ["@azure/core-auth@1.11.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-util": "^1.13.0", "tslib": "^2.6.2" } }, "sha512-IUZydyTUkDnYdstOW9pFOOUQlBjAepK5teihDE3x6yxsPJs/hsAaaYpeGxdxrgtOiJbBKSjKW7MDk7AEhb4LRg=="], + + "@azure/core-client": ["@azure/core-client@1.11.1", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-rest-pipeline": "^1.22.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "tslib": "^2.6.2" } }, "sha512-2QygG2F76ZpMP2eMztiJvAiFMu71M9rDeU7vO/QKg5Css7MgM4frUOslFjhVjRhbGaCNPtz/S8M6y46/fFKVuQ=="], + + "@azure/core-process": ["@azure/core-process@1.0.0", "", {}, "sha512-/shnJ+ooO8WPxDhPEeI/2oRQuubn16gZ6CvlbpWbEswZfzwI9tI/sMAHmF3x1LuQ9yZYXfLW3TjzGMLEC5blKg=="], + + "@azure/core-rest-pipeline": ["@azure/core-rest-pipeline@1.25.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@azure/core-auth": "^1.10.0", "@azure/core-tracing": "^1.3.0", "@azure/core-util": "^1.13.0", "@azure/logger": "^1.3.0", "@typespec/ts-http-runtime": "^0.3.4", "tslib": "^2.6.2" } }, "sha512-bMs8ekJLjX8wPV+9IPBges1SLPyuDtE9g5gLDWOpxzKcoOFQnpLGkbcT1tdw3FaAmDS1gnPmMmJ6y/T5B96kIA=="], + + "@azure/core-tracing": ["@azure/core-tracing@1.4.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-eGwxD0AtncrxeBM4tG8R55Pc3rdX1hNW2WibJAgYpCVA6E93mvvVH+LcssoVjOBrSKWS55yEIHsk0X8ctHmfOQ=="], + + "@azure/core-util": ["@azure/core-util@1.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.1.2", "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-9n2pWK61veAuN0V20t9lOuoV4CFMdyAZ1ygZzvBGk/pBBJRib/PjL9PLXa/aI2CcPpyHfqVsxxqLCYl6uZlfDw=="], + + "@azure/identity": ["@azure/identity@4.14.0", "", { "dependencies": { "@azure/abort-controller": "^2.0.0", "@azure/core-auth": "^1.9.0", "@azure/core-client": "^1.9.2", "@azure/core-process": "^1.0.0", "@azure/core-rest-pipeline": "^1.17.0", "@azure/core-tracing": "^1.0.0", "@azure/core-util": "^1.11.0", "@azure/logger": "^1.0.0", "@azure/msal-browser": "^5.5.0", "@azure/msal-node": "^6.0.0", "open": "^11.0.0", "tslib": "^2.2.0" } }, "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw=="], + + "@azure/logger": ["@azure/logger@1.4.0", "", { "dependencies": { "@typespec/ts-http-runtime": "^0.3.0", "tslib": "^2.6.2" } }, "sha512-rbAE25KUfjU/s3XHUdJgceoCP5dEOpMx85J04kF+QMdta73XkuG9JGHHinch+XIoKpBdqljin+KqURpJriSzLA=="], + + "@azure/msal-browser": ["@azure/msal-browser@5.26.0", "", { "dependencies": { "@azure/msal-common": "16.14.3" } }, "sha512-GYKRquzDZmZggCdStWy2bNJKnPui1upssSuznJgP+/h6Mb0ldkUkaGvaY6uSGGmZOm0CGiBQ3b5BiGy43BpXSg=="], + + "@azure/msal-common": ["@azure/msal-common@16.14.3", "", {}, "sha512-tz3GvMrR9V/plb2lDdW8qaO8CPpfZevdExBKjltzzbAtyhK0eB2UIvji1aU+gkyHj3fxQ2ie+LNHyMXdmo8wOg=="], + + "@azure/msal-node": ["@azure/msal-node@6.0.1", "", { "dependencies": { "@azure/msal-common": "16.14.1", "jsonwebtoken": "^9.0.0" } }, "sha512-ixSO1Y/kCVRthRs+hSx/5qkwaunX1/RAePhlMN0wIpIQ4WEZ6AREGGnGd1AP0qspHVsAwzWQKGubpjh50JyJIQ=="], + + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + + "@secretlint/core": ["@secretlint/core@10.2.2", "", { "dependencies": { "@secretlint/profiler": "^10.2.2", "@secretlint/types": "^10.2.2", "debug": "^4.4.1", "structured-source": "^4.0.0" } }, "sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw=="], + + "@secretlint/profiler": ["@secretlint/profiler@10.2.2", "", {}, "sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig=="], + + "@secretlint/secretlint-rule-no-dotenv": ["@secretlint/secretlint-rule-no-dotenv@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2" } }, "sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg=="], + + "@secretlint/secretlint-rule-preset-recommend": ["@secretlint/secretlint-rule-preset-recommend@10.2.2", "", {}, "sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA=="], + + "@secretlint/source-creator": ["@secretlint/source-creator@10.2.2", "", { "dependencies": { "@secretlint/types": "^10.2.2", "istextorbinary": "^9.5.0" } }, "sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw=="], + + "@secretlint/types": ["@secretlint/types@10.2.2", "", {}, "sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg=="], + + "@typespec/ts-http-runtime": ["@typespec/ts-http-runtime@0.3.9", "", { "dependencies": { "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.0", "tslib": "^2.6.2" } }, "sha512-edSdeAqkdxBVzA1yL1LrLCml1YjyCVvPMtMqJpbF+6K609tHe8V6sQUzFQSGcYNhcuhOceZtjvN32+mpIth30A=="], + + "@vscode/vsce": ["@vscode/vsce@4.0.0", "", { "dependencies": { "@azure/identity": "^4.13.2", "@napi-rs/keyring": "^1.3.0", "@secretlint/core": "^10.2.2", "@secretlint/secretlint-rule-no-dotenv": "^10.2.2", "@secretlint/secretlint-rule-preset-recommend": "^10.2.2", "@secretlint/source-creator": "^10.2.2", "@secretlint/types": "^10.2.2", "@vscode/vsce-sign": "^2.1.0", "azure-devops-node-api": "^12.5.0", "cockatiel": "^3.2.1", "commander": "^12.1.0", "hosted-git-info": "^4.1.0", "jsonc-parser": "^3.3.1", "marked": "^18.0.11", "mime": "^1.6.0", "minimatch": "^10.2.6", "parse5": "^8.0.1", "proper-lockfile": "^4.1.2", "read": "^1.0.7", "semver": "^7.8.5", "tinyglobby": "^0.2.17", "typed-rest-client": "^1.8.11", "url-join": "^4.0.1", "xml2js": "^0.5.0", "yauzl": "^3.4.0", "yazl": "^2.5.1" }, "bin": { "vsce": "vsce" } }, "sha512-NImwuLaenMmb5D5Jer9/lzi/F9ZQUBOp8Azhj/BVYcTFgixv8KehFXqEUDjQlD2tAiw2E6dDGyjTuAB//di60A=="], + + "@vscode/vsce-sign": ["@vscode/vsce-sign@2.1.0", "", { "optionalDependencies": { "@vscode/vsce-sign-alpine-arm64": "2.0.6", "@vscode/vsce-sign-alpine-x64": "2.0.6", "@vscode/vsce-sign-darwin-arm64": "2.0.6", "@vscode/vsce-sign-darwin-x64": "2.0.6", "@vscode/vsce-sign-linux-arm": "2.0.6", "@vscode/vsce-sign-linux-arm64": "2.0.6", "@vscode/vsce-sign-linux-x64": "2.0.6", "@vscode/vsce-sign-win32-arm64": "2.0.6", "@vscode/vsce-sign-win32-x64": "2.0.6" } }, "sha512-9AQrqazrBgTgRSuwleLVXUrIUphY02/SFCh2TKYoLV/xifJAdblhdmEmw5gUrYSPQ3sRwNs9iyCMD14sATEE6g=="], + + "@vscode/vsce-sign-alpine-arm64": ["@vscode/vsce-sign-alpine-arm64@2.0.6", "", { "os": "none", "cpu": "arm64" }, "sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q=="], + + "@vscode/vsce-sign-alpine-x64": ["@vscode/vsce-sign-alpine-x64@2.0.6", "", { "os": "none", "cpu": "x64" }, "sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w=="], + + "@vscode/vsce-sign-darwin-arm64": ["@vscode/vsce-sign-darwin-arm64@2.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ=="], + + "@vscode/vsce-sign-darwin-x64": ["@vscode/vsce-sign-darwin-x64@2.0.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw=="], + + "@vscode/vsce-sign-linux-arm": ["@vscode/vsce-sign-linux-arm@2.0.6", "", { "os": "linux", "cpu": "arm" }, "sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA=="], + + "@vscode/vsce-sign-linux-arm64": ["@vscode/vsce-sign-linux-arm64@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA=="], + + "@vscode/vsce-sign-linux-x64": ["@vscode/vsce-sign-linux-x64@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA=="], + + "@vscode/vsce-sign-win32-arm64": ["@vscode/vsce-sign-win32-arm64@2.0.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg=="], + + "@vscode/vsce-sign-win32-x64": ["@vscode/vsce-sign-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "azure-devops-node-api": ["azure-devops-node-api@12.5.0", "", { "dependencies": { "tunnel": "0.0.6", "typed-rest-client": "^1.8.4" } }, "sha512-R5eFskGvOm3U/GzeAuxRkUsAl0hrAwGgWn6zAd2KrZmrEhWZVqLew4OOupbQlXUuojUzpGtq62SmdhJ06N88og=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "binaryextensions": ["binaryextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-sXnYK/Ij80TO3lcqZVV2YgfKN5QjUWIRk/XSm2J/4bd/lPko3lvk0O4ZppH6m+6hB2/GTu+ptNwVFe1xh+QLQw=="], + + "boundary": ["boundary@2.0.0", "", {}, "sha512-rJKn5ooC9u8q13IMCrW0RSp31pxBCHE3y9V/tp3TdWSLf8Em3p6Di4NBpfzbJge9YjjFEsD0RtFEjtvHL5VyEA=="], + + "brace-expansion": ["brace-expansion@5.0.12", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bundle-name": ["bundle-name@4.1.1", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "cockatiel": ["cockatiel@3.2.1", "", {}, "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q=="], + + "commander": ["commander@12.1.0", "", {}, "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "editions": ["editions@6.22.0", "", { "dependencies": { "version-range": "^4.15.0" } }, "sha512-UgGlf8IW75je7HZjNDpJdCv4cGJWIi6yumFdZ0R7A8/CIhQiWUjyGLCxdHpd8bmyD1gnkfUNK0oeOXqUS2cpfQ=="], + + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "^6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "istextorbinary": ["istextorbinary@9.5.0", "", { "dependencies": { "binaryextensions": "^6.11.0", "editions": "^6.21.0", "textextensions": "^6.11.0" } }, "sha512-5mbUj3SiZXCuRf9fT3ibzbSSEWiy63gFfksmGfdOzujPjW3k+z8WvIBxcJHBoQNlaZaiyB25deviif2+osLmLw=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], + + "lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="], + + "lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="], + + "lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="], + + "lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="], + + "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "marked": ["marked@18.1.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], + + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mute-stream": ["mute-stream@0.0.8", "", {}, "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + + "powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="], + + "read": ["read@1.0.7", "", { "dependencies": { "mute-stream": "~0.0.4" } }, "sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "sax": ["sax@1.6.1", "", {}, "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "structured-source": ["structured-source@4.0.0", "", { "dependencies": { "boundary": "^2.0.0" } }, "sha512-qGzRFNJDjFieQkl/sVOI2dUjHKRyL9dAJi2gCPGJLbJHBIkyOHxjuocpIEfbLioX+qSJpvbYdT49/YCdMznKxA=="], + + "textextensions": ["textextensions@6.11.0", "", { "dependencies": { "editions": "^6.21.0" } }, "sha512-tXJwSr9355kFJI3lbCkPpUH5cP8/M0GGy2xLO34aZCjMXBaK3SoPnZwr/oWmo1FdCnELcs4npdCIOFtq9W3ruQ=="], + + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="], + + "typed-rest-client": ["typed-rest-client@1.8.11", "", { "dependencies": { "qs": "^6.9.1", "tunnel": "0.0.6", "underscore": "^1.12.1" } }, "sha512-5UvfMpd1oelmUPRbbaVnq+rHP7ng2cE4qoQkQeAqxRL6PklkxsM0g32/HL0yfvruK6ojQ5x8EE+HF4YV6DtuCA=="], + + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + + "url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="], + + "version-range": ["version-range@4.15.0", "", {}, "sha512-Ck0EJbAGxHwprkzFO966t4/5QkRuzh+/I1RxhLgUKKwEn+Cd8NwM60mE3AqBZg5gYODoXW0EFsQvbZjRlvdqbg=="], + + "wsl-utils": ["wsl-utils@1.0.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.2.0" } }, "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ=="], + + "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="], + + "yazl": ["yazl@2.5.1", "", { "dependencies": { "buffer-crc32": "~0.2.3" } }, "sha512-phENi2PLiHnHb6QBVot+dJnaAZ0xosj7p3fWl+znIjBDlnMI2PsZCJZ306BPTFOaHf5qdDEI8x5qFrSOBN5vrw=="], + + "@azure/msal-node/@azure/msal-common": ["@azure/msal-common@16.14.1", "", {}, "sha512-Or6xhPNyi4zHW25158yxBoyxuCqNSPa5YBVqfF1J5Ks4MJWBo/USXdp05DQIPu1Zli00YZu6t0+h6KvHJealxQ=="], + } +} diff --git a/editors/vscode/language-configuration.json b/editors/vscode/language-configuration.json new file mode 100644 index 0000000..e3e27e6 --- /dev/null +++ b/editors/vscode/language-configuration.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vscode/master/src/vscode/language-configuration/_languages.schema.json", + "comments": { "lineComment": "//", "blockComment": ["/*", "*/"] }, + "brackets": [["{", "}"], ["(", ")"], ["[", "]"]], + "autoClosingPairs": [ + { "open": "{", "close": "}", "notIn": ["string", "comment"] }, + { "open": "(", "close": ")", "notIn": ["string", "comment"] }, + { "open": "[", "close": "]", "notIn": ["string"] }, + { "open": "'", "close": "'", "notIn": ["string", "comment"] }, + { "open": "\"", "close": "\"", "notIn": ["string", "comment"] } + ], + "surroundingPairs": [ + ["{", "}"], ["(", ")"], ["[", "]"], ["'", "'"], ["\"", "\""] + ], + "indentationRules": { + "increaseIndentPattern": "^\\s*(?:@\\w+[^;{]*)?[^{};]*\\{\\s*$|^(?:mixin|if|each|for|component)\\s+.*$", + "decreaseIndentPattern": "^\\s*\\}" + }, + "wordPattern": "(-?\\d*\\.\\d\\w*)|([^\\s\\w\\{\\}\\(\\)\\[\\]:;,\"']+)", + "onEnterRules": [ + { + "beforeText": "\\{\\s*$", + "afterText": "^(\\s*\\}).*$", + "action": { "indent": "indentOutdent" } + } + ] +} diff --git a/editors/vscode/muddle-language-tools-0.1.0.vsix b/editors/vscode/muddle-language-tools-0.1.0.vsix new file mode 100644 index 0000000000000000000000000000000000000000..b692bef15e0d018c28f6e24bd7445149b1e17300 GIT binary patch literal 13904 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 iMgEVp|1;?SRQ$raw

}\nend"], "description": "Embed a raw HTML block" }, + "Component render": { "prefix": "component", "body": ["component ${1:name}", " ${2:prop}: ${3:value}", " render ${4:slot}", " ${0}", " end? (see docs)"], "description": "Render a component with props and a slot" }, + "Extend layout": { "prefix": "extends", "body": ["extends ./layout.spug", "", "block content", " ${0}"], "description": "Extend a layout and fill the content block" }, + "Render slot": { "prefix": "render", "body": ["render ${1:content}", " ${0}"], "description": "Pass a slot into a component" }, + "Frontmatter": { "prefix": "fm", "body": ["---", "${0}", "---"], "description": "Front matter block with TypeScript imports" }, + "Escape expression": { "prefix": "expr", "body": ["#{${1:expression}}"], "description": "Inline interpolated expression" }, + "Raw expression": { "prefix": "raw", "body": ["!{${1:expression}}"], "description": "Inline raw unescaped expression" }, + "Iterate": { "prefix": "each", "body": ["each ${1:item} in ${2:items}", " ${0}"], "description": "Iterate over a list" }, + "Condition": { "prefix": "if", "body": ["if ${1:condition}", " ${0}"], "description": "Conditional block" }, + "Iso if-choice": { "prefix": "unless", "body": ["unless ${1:condition}", " ${0}"], "description": "Inverse conditional block" }, + "Configuration block": { "prefix": "config", "body": ["$$$", " ${1:block}", "notes", " ${0}"], "description": "Template test configuration and notes" } +} diff --git a/editors/vscode/snippets/stcss.json b/editors/vscode/snippets/stcss.json new file mode 100644 index 0000000..98abbab --- /dev/null +++ b/editors/vscode/snippets/stcss.json @@ -0,0 +1,25 @@ +{ + "Predicted geometry": { "prefix": "predict", "body": ["@predict max-height max-width;"], "description": "Transition prerendered geometry" }, + "Predicted transitions": { "prefix": "predict-full", "body": ["@predict left top width height opacity transform;"], "description": "Full prediction transition list (pill/ribbon style)" }, + "Measure prediction target": { "prefix": "measure", "body": ["@measure;"], "description": "Mark this selector for prerender geometry prefill (inline prediction source)" }, + "Measure anchors": { "prefix": "measure-anchor", "body": ["@measure @${1:anchor}, ${0:.selector};"], "description": "Measure elements by relation anchor or class" }, + "Responsive rule": { "prefix": "responsive", "body": ["@responsive ${1:md} {", " ${2}", "}"], "description": "Add a responsive STCSS rule" }, + "Media rule": { "prefix": "media", "body": ["@media ${1:(max-width: 48rem)} {", " ${2}", "}"], "description": "Add a media query" }, + "Each loop": { "prefix": "each", "body": ["@each $${1:item} in ${2:items} {", " ${0}", "}"], "description": "Repeat a block over a value list" }, + "For loop": { "prefix": "for", "body": ["@for $${1:i} from ${2:1} through ${3:5} {", " ${0}", "}"], "description": "Count a range" }, + "Mixin declaration": { "prefix": "mixin", "body": ["@mixin ${1:name}($${2:arg}) {", " ${0}", "}"], "description": "Declare an STCSS mixin" }, + "Include mixin": { "prefix": "include", "body": ["@include ${1:name}(${2:arg});"], "description": "Use an STCSS mixin" }, + "Interpolation": { "prefix": "interp", "body": ["#{$${1:scale} * 2}rem"], "description": "Compile-time value interpolation" }, + "d-string": { "prefix": "d", "body": ["d\"${1:calc(#{} )}\""], "description": "String with brace expressions evaluated at compile time" }, + "$refer geometry": { "prefix": "refer", "body": ["$refer[${1:left}](@${2:anchor})"], "description": "Read pre-computed anchor geometry (left/top/width/height)" }, + "$refer interpolated location": { "prefix": "refer-between", "body": ["calc($refer[${1:left}](@${2:anchor}) - $refer[${3:left}](@${4:relative}))"], "description": "Compute location between two anchors" }, + "$relate metrics": { "prefix": "relate", "body": ["$relate(${1:@anchor})"], "description": "Legacy relation value of an anchor" }, + "Computed mirror": { "prefix": "computed", "body": ["@${1:anchor}.computed.${2:backgroundColor}"], "description": "Mirror a live computed style of an anchor" }, + "Relation state selector": { "prefix": "with-state", "body": [":with(@${1:anchor}:${2|active,clicked,focus|})"], "description": "Anchor state selection (:has under the hood)" }, + "When selector": { "prefix": "when-state", "body": [":when(${1:root} @${2:anchor}:${3|active,clicked,focus|})"], "description": "Scoped anchor state selection" }, + "Concentric radius": { "prefix": "concentric", "body": ["border-radius: concentric ${1:0.75rem};"], "description": "Concentric nested corner radii" }, + "Freeform gradient": { "prefix": "freeform-gradient", "body": ["background-image: freeform-gradient(${1:#7c3aed} ${2:10%} ${3:50%}, ${4:#f00} ${5:70%} ${6:40%});"], "description": "Spawn layered freeform radial gradients" }, + "Concentric block": { "prefix": "concentric-block", "body": ["@concentric ${1:1rem} {", " ${0}", "}"], "description": "Concentric radius at-rule" }, + "View transition": { "prefix": "view-transition", "body": ["@view-transition ${1:name};"], "description": "Emit a css view-transition-name" }, + "Compile-time values": { "prefix": "javascript", "body": ["javascript!", " const ${1:space} = ${2:1};", "end"], "description": "Add compile-time values" } +} diff --git a/editors/vscode/src/extension.js b/editors/vscode/src/extension.js new file mode 100644 index 0000000..7bc1369 --- /dev/null +++ b/editors/vscode/src/extension.js @@ -0,0 +1,466 @@ +const vscode = require("vscode"); + +const AT_RULES = new Set([ + "each", "for", "from", "through", "include", "responsive", "media", "supports", + "concentric", "view-transition", "predict", "measure", "property", "mixin", + "stcss", "import", "charset", "document", "font-face", "keyframes", "layer", + "namespace", "page", "scope", "starting-style", "container", +]); + +const STATE_WORDS = new Set(["active", "clicked", "focus", "hover", "open", "closed", "selected", "loading", "visible", "hidden"]); + +const AT_RULE_DOCS = new Map([ + ["mixin", "Declare a reusable STCSS mixin: `@mixin name($param) { … }`"], + ["include", "Expand a mixin: `@include name(10%);`"], + ["each", "Repeat rules over values: `@each $item in a b c { … }`"], + ["for", "Count a range: `@for $i from 1 through 5 { … }`"], + ["responsive", "Wrap rules in a named breakpoint from the config."], + ["media", "Standard `@media` query wrapper."], + ["supports", "Standard `@supports` feature wrapper."], + ["concentric", "Emit concentric nested radii: `@concentric 0.75rem { … }`"], + ["view-transition", "Emit `view-transition-name` on the attached rule."], + ["predict", "Declare which properties transition when prediction prefill changes (emits a smooth transition rule)."], + ["measure", "Mark the attached rule (or listed selectors / `@anchor`s) as a prediction target. Prerender measures the elements and writes geometry. Bare `@anchor` arguments compile to `[data-muddle-anchor=\"anchor\"]`."], +]); + +const REFERENCE_DOC = "Reads pre-computed geometry recorded from a `@sample` anchor: compiles to `var(--muddle-relate-…)`.\n\nMetrics: `left`, `top`, `width`, `height`. `@anchor` compiles to `[data-muddle-anchor=\"anchor\"]`."; + +const RELATE_DOC = "Legacy relation value of an anchor (deprecated in favour of `$refer[metric](@anchor)`)."; + +const CSS_PROPERTIES = [ + "background", "background-color", "background-image", "border", "border-color", "border-radius", "border-width", + "bottom", "box-shadow", "box-sizing", "color", "cursor", "display", "flex", "flex-basis", "flex-direction", + "flex-shrink", "flex-wrap", "font-family", "font-size", "font-weight", "gap", "grid-area", "height", "inset", + "justify-content", "left", "letter-spacing", "line-height", "margin", "max-height", "max-width", "min-height", + "min-width", "opacity", "overflow", "padding", "pointer-events", "position", "right", "text-decoration", + "text-transform", "top", "transform", "transition", "vertical-align", "visibility", "white-space", "width", + "z-index", +]; + +const HTML_ATTRIBUTES = [ + "alt", "aria-controls", "aria-current", "aria-description", "aria-expanded", "aria-hidden", "aria-label", + "aria-live", "autocomplete", "class", "content", "href", "id", "placeholder", "rel", "role", "src", "style", + "tabindex", "target", "title", "type", +]; + +const INDEX = { + anchors: new Map(), // name -> [{ uri, line, kind }] + variables: new Map(), // $name -> [{ uri, line }] + spugFiles: [], + styleFiles: [], +}; + +let indexToken = 0; + +function relativeToWorkspace(uri) { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (!folder) return uri.fsPath.split(/[\\/]/).pop(); + return uri.fsPath.slice(folder.uri.fsPath.length + 1); +} + +function debounce(callback, wait) { + let timer; + return (...args) => { + clearTimeout(timer); + timer = setTimeout(() => callback(...args), wait); + }; +} + +async function readText(uri) { + try { + const bytes = await vscode.workspace.fs.readFile(uri); + return new TextDecoder().decode(bytes); + } catch { + return ""; + } +} + +function posFromOffset(text, offset) { + const before = text.slice(0, offset); + const newline = before.lastIndexOf("\n"); + return new vscode.Position(before.split("\n").length - 1, offset - newline - 1); +} + +function rangeOf(text, offset, length) { + return new vscode.Range(posFromOffset(text, offset), posFromOffset(text, offset + Math.max(length, 1))); +} + +function isStcss(document) { + return document.languageId === "stcss"; +} + +function rememberAnchor(name, uri, line, kind) { + const list = INDEX.anchors.get(name) ?? []; + list.push({ uri, line, kind }); + INDEX.anchors.set(name, list); +} + +function indexSpugDocument(uri, text) { + const lines = text.split("\n"); + const anchorRegex = /@([A-Za-z_][\w-]*)(?:\(([^(){}]*)\))?/g; + lines.forEach((line, index) => { + let match; + anchorRegex.lastIndex = 0; + while ((match = anchorRegex.exec(line))) { + if (AT_RULES.has(match[1]) || match[1] === "sample") continue; + rememberAnchor(match[1], uri, index + 1, "declared in SPUG"); + } + }); +} + +function indexStcssDocument(uri, text) { + const variableRegex = /(\$[\w-]+)\s*:/g; + let match; + while ((match = variableRegex.exec(text))) { + const position = posFromOffset(text, match.index); + const list = INDEX.variables.get(match[1]) ?? []; + list.push({ uri, line: position.line + 1 }); + INDEX.variables.set(match[1], list); + } + + const anchorRegex = /@([A-Za-z_][\w-]*)/g; + while ((match = anchorRegex.exec(text))) { + if (AT_RULES.has(match[1])) continue; + rememberAnchor(match[1], uri, posFromOffset(text, match.index).line + 1, "referenced in STCSS"); + } +} + +async function indexWorkspace() { + const token = ++indexToken; + if (!vscode.workspace.workspaceFolders) return; + const [spugFiles, styleFiles] = await Promise.all([ + vscode.workspace.findFiles("**/*.spug", "**/node_modules/**"), + vscode.workspace.findFiles("**/*.stcss", "**/node_modules/**"), + ]); + if (token !== indexToken) return; + INDEX.anchors = new Map(); + INDEX.variables = new Map(); + INDEX.spugFiles = spugFiles; + INDEX.styleFiles = styleFiles; + await Promise.all([ + ...spugFiles.map(async (uri) => indexSpugDocument(uri, await readText(uri))), + ...styleFiles.map(async (uri) => indexStcssDocument(uri, await readText(uri))), + ]); +} + +function anchorCompletions() { + return [...INDEX.anchors.keys()].map((name) => { + const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Keyword); + item.detail = "relation anchor"; + const first = INDEX.anchors.get(name)[0]; + if (first) item.documentation = new vscode.MarkdownString(`${first.kind}: \`${relativeToWorkspace(first.uri)}:${first.line}\``); + return item; + }); +} + +function stcssCompletions() { + const atRules = [...AT_RULE_DOCS.keys()].map((name) => { + const item = new vscode.CompletionItem(`@${name}`, vscode.CompletionItemKind.Struct); + item.detail = "stcss at-rule"; + item.insertText = new vscode.SnippetString(name === "concentric" || name === "media" ? `${name} $1 {\n\t$0\n}` : `${name};`); + item.documentation = new vscode.MarkdownString(AT_RULE_DOCS.get(name)); + return item; + }); + const metrics = ["left", "top", "width", "height"].map((metric) => { + const item = new vscode.CompletionItem(`$refer[${metric}](@)`, vscode.CompletionItemKind.Function); + item.insertText = new vscode.SnippetString(`$refer[${metric}](@\${1:anchor})`); + item.documentation = new vscode.MarkdownString(REFERENCE_DOC); + return item; + }); + const relate = new vscode.CompletionItem("$relate(@)", vscode.CompletionItemKind.Function); + relate.insertText = new vscode.SnippetString("$relate(@${1:anchor})"); + relate.documentation = new vscode.MarkdownString(RELATE_DOC); + const states = [...STATE_WORDS].map((state) => { + const item = new vscode.CompletionItem(state, vscode.CompletionItemKind.EnumMember); + item.detail = "anchor state"; + return item; + }); + const properties = CSS_PROPERTIES.map((name) => { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property); + item.insertText = new vscode.SnippetString(`${name}: $0;`); + return item; + }); + const variables = [...INDEX.variables.keys()].map((name) => { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Variable); + item.detail = "stcss variable"; + return item; + }); + return [...atRules, ...metrics, relate, ...states, ...properties, ...variables]; +} + +function spugCompletions() { + const keywords = ["extends", "include", "block", "append", "prepend", "mixin", "slot", "yield", "island", "component", "render", "doctype"].map((word) => { + const item = new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword); + return item; + }); + const javascript = new vscode.CompletionItem("javascript!", vscode.CompletionItemKind.Keyword); + javascript.insertText = new vscode.SnippetString("javascript!\n\t$0\nend"); + const control = ["if", "else", "unless", "each", "while", "case", "when", "default", "props"].map((word) => new vscode.CompletionItem(word, vscode.CompletionItemKind.Keyword)); + const attributes = HTML_ATTRIBUTES.map((name) => { + const item = new vscode.CompletionItem(name, vscode.CompletionItemKind.Property); + item.insertText = new vscode.SnippetString(`${name}=$0`); + return item; + }); + return [...keywords, javascript, ...control, ...attributes, ...anchorCompletions()]; +} + +function stcssHover(document, position) { + const line = document.lineAt(position.line).text; + const preceding = line.slice(0, position.character); + const anchor = /(?:^|[^\w$])@([A-Za-z_][\w-]*)$/.exec(preceding)?.[1]; + if (anchor) return hoverAnchor(anchor); + const variable = /\$[\w-]+$/.exec(preceding)?.[0]; + if (variable) { + const hits = INDEX.variables.get(variable) ?? []; + return new vscode.Hover(new vscode.MarkdownString( + hits.length ? `**${variable}**: defined in ${hits.map((h) => `${relativeToWorkspace(h.uri)}:${h.line}`).join(", ")}` : `**${variable}**`, + )); + } + const refer = /(?:\$\w+(?:\[[\w-]+\])?)$/.exec(preceding)?.[0]; + if (refer) { + if (refer.startsWith("$refer")) return new vscode.Hover(new vscode.MarkdownString(REFERENCE_DOC)); + if (refer.startsWith("$relate")) return new vscode.Hover(new vscode.MarkdownString(RELATE_DOC)); + } + return null; +} + +function hoverAnchor(name) { + const hits = INDEX.anchors.get(name) ?? []; + if (!hits.length) return null; + const body = hits.slice(0, 8).map((hit) => `- ${hit.kind}: \`${relativeToWorkspace(hit.uri)}:${hit.line}\``).join("\n"); + return new vscode.Hover(new vscode.MarkdownString(`**@${name}**: relation anchor\n\n${body}`)); +} + +function spugHover(document, position) { + const range = document.getWordRangeAtPosition(position, /@?[A-Za-z_][\w-]*/); + if (!range) return null; + const word = document.getText(range); + if (word.startsWith("@") || document.getText(new vscode.Range(range.start.translate({ characterDelta: -1 }), range.start)) === "@") { + return hoverAnchor(word.replace(/^@/, "")); + } + return null; +} + +function anchorDefinition(name) { + return (INDEX.anchors.get(name) ?? []) + .filter((hit) => hit.kind === "declared in SPUG") + .map((hit) => new vscode.Location(hit.uri, new vscode.Position(hit.line - 1, 0))); +} + +function stcssDefinition(document, position) { + const line = document.lineAt(position.line).text.slice(0, position.character + 1); + const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(line)?.[1]; + if (anchor) return anchorDefinition(anchor); + return null; +} + +function spugDefinition(document, position) { + const before = document.lineAt(position.line).text.slice(0, position.character + 1); + const anchor = /@([A-Za-z_][\w-]*)[^\w]?$/.exec(before)?.[1]; + if (anchor) { + // jump from anchor to its STCSS references + const refs = (INDEX.anchors.get(anchor) ?? []).filter((hit) => hit.kind === "referenced in STCSS"); + return refs.map((hit) => new vscode.Location(hit.uri, new vscode.Range(hit.line - 1, 0, hit.line - 1, 200))); + } + return null; +} + +function diagnosticCollection() { + return vscode.languages.createDiagnosticCollection("muddle"); +} + +function lintDocument(document, collection) { + if (document.languageId !== "stcss" && document.languageId !== "spug") return []; + const text = document.getText(); + const issues = []; + + // balanced braces in both languages + let depth = 0; + let lastOpen = 0; + for (let index = 0; index < text.length && issues.length < 40; index += 1) { + const char = text[index]; + if (char === "{") { + depth += 1; + lastOpen = index; + } else if (char === "}") { + depth -= 1; + if (depth < 0) { + issues.push(new vscode.Diagnostic(rangeOf(text, index, 1), "Unexpected `}`.", vscode.DiagnosticSeverity.Error)); + depth = 0; + } + } + } + if (depth > 0) { + issues.push(new vscode.Diagnostic(rangeOf(text, lastOpen, 1), "Unclosed `{` block.", vscode.DiagnosticSeverity.Error)); + } + + if (document.languageId === "stcss") { + const anchorRegex = /@([A-Za-z_][\w-]*)/g; + let match; + while ((match = anchorRegex.exec(text))) { + const name = match[1]; + const offset = match.index; + const previous = text[offset - 1]; + const dynamic = text.slice(offset, text.indexOf("\n", offset) === -1 ? text.length : text.indexOf("\n", offset)).includes("("); + if (AT_RULES.has(name) || previous === "$" || dynamic) continue; + if (!INDEX.anchors.has(name)) { + issues.push(new vscode.Diagnostic( + rangeOf(text, offset, match[0].length), + `No SPUG file declares relation anchor \`@${name}\`.`, + vscode.DiagnosticSeverity.Warning, + )); + } + } + } + + collection.set(document.uri, issues); + return issues; +} + +function lintVisible() { + for (const editor of vscode.window.visibleTextEditors) lintDocument(editor.document, diagnostics); +} + +function symbolKindFor(document) { + return isStcss(document) ? vscode.SymbolKind.Class : vscode.SymbolKind.Module; +} + +function symbolsOf(document) { + const flat = []; + for (let line = 0; line < document.lineCount; line += 1) { + const text = document.lineAt(line).text; + const match = /^(\s*)(@?[A-Za-z][^\n{;]*?|@[\w-]+)\s*\{?\s*$/.exec(text); + if (!match || !text.trim()) continue; + if (isStcss(document) && !text.includes("{")) continue; + if (!isStcss(document) && text.includes("{")) continue; + const label = match[2].trim() || "root"; + const symbol = new vscode.DocumentSymbol( + label, + "", + symbolKindFor(document), + new vscode.Range(line, 0, line, text.length), + new vscode.Range(line, match[1].length, line, match[1].length + label.length), + ); + flat.push({ symbol, indent: match[1].length }); + } + const roots = []; + const stack = []; + for (const entry of flat) { + while (stack.length && entry.indent <= stack[stack.length - 1].indent) stack.pop(); + if (stack.length) stack[stack.length - 1].symbol.children.push(entry.symbol); + else roots.push(entry.symbol); + stack.push(entry); + } + return roots; +} + +function foldingOf(document) { + const ranges = []; + const stack = []; + for (let line = 0; line < document.lineCount; line += 1) { + const text = document.lineAt(line).text; + if (isStcss(document)) { + const opens = (text.match(/\{/g) ?? []).length; + const closes = (text.match(/\}/g) ?? []).length; + for (let i = 0; i < opens; i += 1) stack.push(line); + for (let i = 0; i < closes && stack.length; i += 1) { + const start = stack.pop(); + if (line > start) ranges.push(new vscode.FoldingRange(start, line)); + } + } else { + const indent = text.match(/^(\s*)/)[1].length; + if (!text.trim()) continue; + while (stack.length && indent <= stack[stack.length - 1].indent) { + const start = stack.pop(); + if (line - 1 > start) ranges.push(new vscode.FoldingRange(start, line - 1)); + } + stack.push({ indent, line }); + } + } + while (stack.length) { + const start = typeof stack[stack.length - 1] === "number" ? stack.pop() : stack.pop().line; + if (document.lineCount - 1 > start) ranges.push(new vscode.FoldingRange(start, document.lineCount - 1)); + } + return ranges; +} + +let diagnostics; + +function completionsFor(document, position) { + const line = document.lineAt(position.line); + const before = line.text.slice(0, position.character); + if (line.isEmptyOrWhitespace) return []; + const prefix = before.trim(); + if (prefix.length === 0 && position.character > 0) return []; + // completions popped while just typing whitespace mid-line are noise + if (before.length > 0 && /\s$/.test(before) && !/[$@:(\[=,{]\s*$/.test(before)) return []; + return isStcss(document) ? stcssCompletions(before) : spugCompletions(before); +} + +function activate(context) { + diagnostics = diagnosticCollection(); + + const refresh = debounce(async () => { + await indexWorkspace(); + lintVisible(); + }, 500); + + Promise.resolve(refresh()); + + context.subscriptions.push( + diagnostics, + + vscode.languages.registerCompletionItemProvider( + ["stcss", "spug"], + { provideCompletionItems(document, position) { return completionsFor(document, position); } }, + "@", "$", ".", "-", ":", "=", + ), + + vscode.languages.registerHoverProvider(["stcss", "spug"], { + provideHover(document, position) { + const word = document.getWordRangeAtPosition(position, /[@$]?[\w-]+/); + if (!word) return null; + try { + return isStcss(document) ? stcssHover(document, position) : spugHover(document, position); + } catch { + return null; + } + }, + }), + + vscode.languages.registerDefinitionProvider(["stcss", "spug"], { + provideDefinition(document, position) { + try { + return isStcss(document) ? stcssDefinition(document, position) : spugDefinition(document, position); + } catch { + return null; + } + }, + }), + + vscode.languages.registerDocumentSymbolProvider(["stcss", "spug"], { + provideDocumentSymbols(document) { return symbolsOf(document); }, + }), + + vscode.languages.registerFoldingRangeProvider(["stcss", "spug"], { + provideFoldingRanges(document) { return foldingOf(document); }, + }), + + vscode.workspace.onDidOpenTextDocument((document) => lintDocument(document, diagnostics)), + vscode.workspace.onDidChangeTextDocument((event) => { + lintDocument(event.document, diagnostics); + refresh(); + }), + vscode.workspace.onDidSaveTextDocument(refresh), + vscode.workspace.onDidCreateFiles(refresh), + vscode.workspace.onDidDeleteFiles(refresh), + ); + + lintVisible(); +} + +function deactivate() { + return undefined; +} + +module.exports = { activate, deactivate }; diff --git a/editors/vscode/syntaxes/spug.tmLanguage.json b/editors/vscode/syntaxes/spug.tmLanguage.json new file mode 100644 index 0000000..755d656 --- /dev/null +++ b/editors/vscode/syntaxes/spug.tmLanguage.json @@ -0,0 +1,200 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.schema.json", + "name": "SPUG", + "scopeName": "source.spug", + "patterns": [ + { "include": "#frontmatter" }, + { "include": "#comment" }, + { "include": "#javascript-block" }, + { "include": "#doctype" }, + { "include": "#control-line" }, + { "include": "#keyword-line" }, + { "include": "#element" } + ], + "repository": { + "comment": { + "patterns": [ + { + "name": "comment.block.spug", + "begin": "/\\*", + "end": "\\*/" + }, + { + "name": "comment.block.html.spug", + "begin": "" + } + ] + }, + "frontmatter": { + "name": "meta.embedded.block.ts.spug", + "begin": "^---\\s*$", + "beginCaptures": { "0": { "name": "punctuation.definition.frontmatter.begin.spug" } }, + "end": "^---\\s*$", + "endCaptures": { "0": { "name": "punctuation.definition.frontmatter.end.spug" } }, + "patterns": [{ "include": "source.ts" }] + }, + "javascript-block": { + "name": "meta.embedded.block.ts.spug", + "begin": "^\\s*(javascript|typescript)!\\s*$", + "beginCaptures": { "1": { "name": "keyword.control.spug" } }, + "end": "^\\s*end\\s*$", + "endCaptures": { "0": { "name": "keyword.control.spug" } }, + "patterns": [{ "include": "source.ts" }] + }, + "doctype": { + "match": "^\\s*(doctype)\\b\\s*(\\S+)?", + "captures": { + "1": { "name": "keyword.control.spug" }, + "2": { "name": "constant.other.doctype.spug" } + } + }, + "control-line": { + "name": "meta.spug-control.spug", + "begin": "^\\s*(each|if|unless|else if|else|while|case|when|default)\\b", + "beginCaptures": { "1": { "name": "keyword.control.spug" } }, + "end": "$", + "patterns": [ + { "match": "\\bin\\b", "name": "keyword.operator.of.spug" }, + { "include": "#interpolation" }, + { "include": "#expression" } + ] + }, + "keyword-line": { + "match": "^\\s*(extends|include|block|append|prepend|mixin|slot|yield|island|component|render|props)\\b\\s*([\\w./@-]*)", + "captures": { + "1": { "name": "keyword.control.spug" }, + "2": { + "patterns": [ + "string.quoted.other.path.spug", + { "match": "[\\w./@-]+", "name": "variable.other.predefined.spug" } + ] + } + } + }, + "anchor": { + "match": "@(?!(?:each|if|unless|else|while|case|when|default|props|doctype|javascript|typescript|end)\\b)[A-Za-z_][\\w-]*(?:\\([^(){}]*\\))?", + "name": "entity.other.relation-anchor.spug" + }, + "element": { + "name": "meta.element.spug", + "begin": "^\\s*([A-Za-z][A-Za-z0-9:_.-]*)", + "beginCaptures": { + "1": { "name": "entity.name.tag.spug" } + }, + "end": "$", + "patterns": [ + { "include": "#class-id" }, + { "include": "#anchor" }, + { "include": "#attributes" }, + { "include": "#output" }, + { "include": "#interpolation" }, + { "include": "#text" } + ] + }, + "class-id": { + "patterns": [ + { + "match": "(\\.)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.tag.spug" }, + "2": { "name": "entity.other.attribute-name.class.spug" } + } + }, + { + "match": "(#)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.tag.spug" }, + "2": { "name": "entity.other.attribute-name.id.spug" } + } + } + ] + }, + "attributes": { + "name": "meta.attributes.spug", + "begin": "\\(", + "end": "\\)", + "patterns": [ + { "include": "#anchor" }, + { "include": "#attributes" }, + { + "match": "([A-Za-z_][\\w:-]*)\\s*(?==)", + "name": "entity.other.attribute-name.spug" + }, + { "include": "#d-string" }, + { "include": "#expression" } + ] + }, + "d-string": { + "name": "string.quoted.double.stcss", + "begin": "([dD])(\"|')", + "beginCaptures": { + "1": { "name": "keyword.other.stcss" }, + "2": { "name": "punctuation.definition.string.begin.stcss" } + }, + "end": "(?=!]=[?]?|[?:,+\\-*/%<>=!]", "name": "keyword.operator.js" } + ] + } + } +} diff --git a/editors/vscode/syntaxes/stcss.tmLanguage.json b/editors/vscode/syntaxes/stcss.tmLanguage.json new file mode 100644 index 0000000..94fbcf9 --- /dev/null +++ b/editors/vscode/syntaxes/stcss.tmLanguage.json @@ -0,0 +1,251 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.schema.json", + "name": "Structured CSS (STCSS)", + "scopeName": "source.stcss", + "patterns": [ + { "include": "#comment" }, + { "include": "#javascript-block" }, + { "include": "#variable-definition" }, + { "include": "#mixin-declaration" }, + { "include": "#rule" }, + { "include": "#at-rule" }, + { "include": "#value" } + ], + "repository": { + "comment": { + "patterns": [ + { + "name": "comment.block.stcss", + "begin": "/\\*", + "end": "\\*/" + }, + { + "name": "comment.line.double-slash.stcss", + "match": "//[^{]*$" + } + ] + }, + "javascript-block": { + "name": "meta.embedded.block.ts.stcss", + "begin": "^\\s*(javascript|typescript)!\\s*$", + "beginCaptures": { "1": { "name": "keyword.control.stcss" } }, + "end": "^\\s*end\\s*$", + "endCaptures": { "0": { "name": "keyword.control.stcss" } }, + "patterns": [{ "include": "source.ts" }] + }, + "variable-definition": { + "match": "^\\s*(\\$[\\w-]+)\\s*:", + "captures": { + "1": { "name": "variable.definition.stcss" } + } + }, + "mixin-declaration": { + "match": "^\\s*(\\$[\\w-]+)\\s*\\(|^\\s*(mixin)\\s+([\\w-]+)\\s*(\\([^()]*\\))?", + "captures": { + "1": { "name": "variable.definition.stcss" }, + "2": { "name": "keyword.control.stcss" }, + "3": { "name": "entity.name.function.stcss" }, + "4": { "name": "meta.function.parameters.stcss" } + } + }, + "rule": { + "name": "meta.rule.stcss", + "begin": "^([^\\n{}]*?)\\s*\\{", + "end": "\\}", + "beginCaptures": { + "1": { "name": "meta.selector.stcss", "patterns": [{ "include": "#selector-content" }] } + }, + "endCaptures": { "0": { "name": "punctuation.definition.block.end.bracket.curly.stcss" } }, + "patterns": [ + { "include": "#comment" }, + { "include": "#javascript-block" }, + { "include": "#variable-definition" }, + { "include": "#mixin-declaration" }, + { "include": "#rule" }, + { "include": "#at-rule" }, + { "include": "#declaration" }, + { "include": "#value" } + ] + }, + "at-rule": { + "match": "(@)(each|for|include|responsive|media|supports|concentric|view-transition|predict|measure|property|import|keyframes|layer|container|font-face|charset|document|page|scope|starting-style)(\\b[^;{\\n]*)?", + "captures": { + "1": { "name": "punctuation.definition.at-rule.stcss" }, + "2": { + "patterns": [ + { + "match": "^(?:each|for|include|responsive|concentric|predict|measure|property)$", + "name": "keyword.other.at-rule.muddle" + }, + { + "match": "^.+$", + "name": "keyword.control.at-rule.stcss" + } + ] + }, + "3": { + "patterns": [ + { "include": "#interpolation" }, + { "include": "#anchor" }, + { "include": "#value" } + ] + } + } + }, + "selector-content": { + "patterns": [ + { "include": "#interpolation" }, + { "include": "#anchor" }, + { "include": "#variable" }, + { + "match": "(\\.)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.class.stcss" }, + "2": { "name": "entity.other.attribute-name.class.stcss" } + } + }, + { + "match": "(#)([A-Za-z_][\\w-]*)", + "captures": { + "1": { "name": "punctuation.definition.id.stcss" }, + "2": { "name": "entity.other.attribute-name.id.stcss" } + } + }, + { "match": "(&)", "name": "keyword.operator.parent-reference.stcss" }, + { "match": "[>+~]", "name": "keyword.operator.combinator.stcss" }, + { + "match": "[\\w-]+(?=\\s*(?:$|[.#:&,*]|[\\[{]|\\s|,))", + "name": "entity.name.tag.stcss" + }, + { "include": "#pseudoclass" }, + { "include": "#attribute-selector" } + ] + }, + "pseudoclass": { + "patterns": [ + { + "match": ":with|:when", + "name": "keyword.other.relation-selector.stcss" + } + ] + }, + "attribute-selector": { + "name": "meta.attribute-selector.stcss", + "begin": "\\[", + "beginCaptures": { "0": { "name": "punctuation.definition.attribute-selector.begin.stcss" } }, + "end": "\\]", + "endCaptures": { "0": { "name": "punctuation.definition.attribute-selector.end.stcss" } }, + "patterns": [ + { "include": "#interpolation" }, + { "include": "#variable" }, + { + "match": "([a-zA-Z-][\\w-]*)\\s*(?=(?:[~^|$]?=)|\\])", + "captures": { + "1": { "name": "entity.other.attribute-name.attribute.stcss" } + } + }, + { "match": "[~^|$]?=", "name": "keyword.operator.stcss" }, + { "include": "#string" }, + { "include": "#number" } + ] + }, + "declaration": { + "match": "^\\s*([-a-zA-Z][\\w-]*)\\s*:", + "captures": { + "1": { "name": "support.type.property-name.css" } + } + }, + "anchor": { + "match": "@[A-Za-z_][\\w-]*(?:\\.computed\\.[\\w-]+)?(?::(?:active|clicked|focus|hover|open|closed|selected|loading|visible|hidden)\\b)?", + "name": "entity.other.relation-anchor.stcss" + }, + "refer": { + "match": "\\$(?:refer(\\[[\\w-]+\\])?|relate)", + "captures": { + "1": { "name": "keyword.other.unit.reference-metric.stcss" } + }, + "name": "support.function.refer.stcss" + }, + "interpolation": { + "name": "meta.interpolation.stcss", + "begin": "#\\{", + "beginCaptures": { "0": { "name": "punctuation.definition.interpolation.begin.stcss" } }, + "end": "\\}", + "endCaptures": { "0": { "name": "punctuation.definition.interpolation.end.stcss" } }, + "patterns": [{ "include": "#curly-nesting" }, { "include": "#expression" }] + }, + "curly-nesting": { + "begin": "\\{", + "beginCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } }, + "end": "\\}", + "endCaptures": { "0": { "name": "punctuation.definition.brace.curly.stcss" } }, + "patterns": [ + { "include": "#curly-nesting" }, + { "include": "#expression" } + ] + }, + "d-string": { + "name": "string.quoted.stcss", + "begin": "(?=?|[*/%+-]", + "name": "keyword.operator.stcss" + }, + "expression": { + "patterns": [ + { "include": "#comment" }, + { "include": "#d-string" }, + { "include": "#string" }, + { "include": "#refer" }, + { "include": "#interpolation" }, + { "include": "#anchor" }, + { "include": "#number" }, + { "include": "#color" }, + { "include": "#variable" }, + { "match": "--[\\w-]+", "name": "variable.other.css.custom-property.stcss" }, + { "include": "#function-call" }, + { "include": "#operator" } + ] + }, + "value": { + "patterns": [{ "include": "#expression" }] + } + } +} diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..b323215 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,42 @@ +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + js.configs.recommended, + ...tseslint.configs.recommended, + { + ignores: ["node_modules/**", "dist/**", "**/dist/**"], + }, + { + files: ["editors/vscode/src/**"], + languageOptions: { + sourceType: "commonjs", + globals: { + require: "readonly", + module: "writable", + setTimeout: "readonly", + clearTimeout: "readonly", + TextDecoder: "readonly", + console: "readonly", + }, + }, + rules: { + "@typescript-eslint/no-require-imports": "off", + }, + }, + { + languageOptions: { + ecmaVersion: 2023, + sourceType: "module", + }, + rules: { + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, + ], + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-object-type": "off", + }, + }, +); diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..4a995a4 --- /dev/null +++ b/index.ts @@ -0,0 +1,237 @@ +// Contracts +export type { + AdapterOptions, + MuddleConfig, + CollectionConfig, + CollectionEntry, + ContentSchema, + DatabaseOptions, + Integration, + IntegrationContext, + MountableViews, + Mounts, + Loader, + LoaderFile, + LoadedFile, + PrerenderOptions as PrerenderConfig, + ResolvedConfig, +} from "./contracts/index.ts"; +export { defineConfig } from "./contracts/index.ts"; +export type { + HttpMethod, + HttpRequest, + HttpResponse, + RequestContext, + ResponseBuilder, + StatusCode, +} from "./contracts/typegen/http/index.ts"; +export type { + ColumnMapping, + FieldOptions, + FieldType, + TableClass, + TableOptions, + WhereCondition, + WhereOperator, +} from "./contracts/typegen/orm/index.ts"; +export type { + TableName, + TableRowOf, + TableRows, +} from "./contracts/typegen/orm/tables.ts"; +export type { + ViewPath, + ViewProps, + ViewPropsOf, +} from "./contracts/typegen/views.ts"; +export type { Minify, Simplify } from "./contracts/typegen/minify.ts"; + +// Core +export { App, type AppOptions, type ViewRenderer } from "./core/app.ts"; +export { + Container, + type AbstractType, + type ProviderDefinition, + type ProviderScope, + type Token, + type Type, +} from "./core/container.ts"; +export { resolveConfig, loadConfig } from "./core/config.ts"; +export { ServerBuildContext, TemplateContext } from "./core/context.ts"; +export { MemorySessionStore, type SessionStore, type SessionRecord } from "./core/sessions.ts"; +export { RoutePipeline, type RouteGuard } from "./core/pipeline.ts"; +export { format } from "./core/format.ts"; +export { createTemplate, muddleTemplates, type MuddleTemplate, type TemplateResult } from "./core/templates.ts"; +export { + MuddleError, + RouteNotFoundError, + SchemaError, + TemplateError, + UnknownProviderError, +} from "./core/errors.ts"; +export { + createRequestContext, + html, + json, + notFound, + redirect, + parseQuery, + parseRequestBody, + text, +} from "./core/http.ts"; +export { + defineMetadata, + getMetadata, + getMemberMetadata, + installMetadataPolyfill, +} from "./core/metadata.ts"; +export { ModuleRegistry, type ModuleContext } from "./core/modules.ts"; +export { + Router, + type RouteDefinition, + type RouteMatch, + type ViewDescriptor, +} from "./core/router.ts"; +export { + redirectWithMessage, + toFormFields, + validate, + type FormSchema, + type ValidationResult, +} from "./core/form.ts"; +export { + ContentRegistry, + ContentService, + validateFrontmatter, +} from "./core/content.ts"; + +// Decorators +export { Component, getComponentMetadata } from "./decorators/Component.ts"; +export { Controller, getControllerPrefix } from "./decorators/Controller.ts"; +export { Inject, Optional } from "./decorators/Inject.ts"; +export { Injectable, Module, getModuleOptions } from "./decorators/Module.ts"; +export { + Body, + Form, + Locals, + Param, + Query, + Req, + Res, +} from "./decorators/Params.ts"; +export { Route, getRoutes } from "./decorators/Route.ts"; +export { Service } from "./decorators/Service.ts"; +export { View, getViews } from "./decorators/View.ts"; +export { RenderAhead, getRenderAhead, type RenderAheadSource } from "./decorators/RenderAhead.ts"; +export { RenderRoute, getRenderRoutes, routeFolder, collectionRoute, type CollectionRenderRoute, type RenderRouteSource } from "./decorators/RenderRoute.ts"; +export { Astro, type AstroOptions } from "./decorators/Astro.ts"; +export { Auth, getAuth, type AuthOptions } from "./decorators/Auth.ts"; + +// Views +export { + createViews, + ViewBuilder, + Views, + type SpugComponentOptions, + type RenderOptions, + type RenderResult, + type ViewsOptions, +} from "./views/api.ts"; +export { + defineComponent, + type ComponentContext, + type ComponentDefinition, + type ComponentRegistry, + type ServerComponent, + type SpugComponentRender, +} from "./views/components.ts"; +export { compileCss, stcssLoader, sassLoader, styleTag, type SassLoaderOptions } from "./views/css/index.ts"; +export { analyzeReactComponent, backportProject, cssToStcss, detectBackportSource, htmlToSpug, reactToSpug, type BackportResult, type BackportSource, type ReactHookAnalysis } from "./core/backport.ts"; +export { + markdownLoader, + parseFrontmatter, + renderLatex, + renderMarkdown, + type Frontmatter, +} from "./views/markdown.ts"; +export { + TemplateEngine, + helpers, + mergeBlocks, + type CompiledTemplate, + type RenderContext, + type TemplateRuntime, +} from "./views/engine/runtime.ts"; +export { SPUG_COMPLETIONS, STCSS_COMPLETIONS, type LanguageCompletion } from "./views/language.ts"; +export { stage, StageManager, type KeyframeStep, type ScrollSceneOptions, type TransitionPortalOptions } from "./views/stage.ts"; +export { + createHooks, + defineHooks, + MuddleHooks, + type HookBuilder, + type SpugExpansion, + type SpugHookContext, + type StcssAtRuleExpansion, + type StcssAtRuleHookContext, + type StcssCalculationExpansion, + type StcssCalculationHookContext, + type StcssDeclarationExpansion, + type StcssDeclarationHookContext, + type StcssValueExpansion, + type StcssValueHookContext, +} from "./views/hooks.ts"; + +export { filesystemCache, cloudflareCache, type Cache, type CloudflareCacheLike } from "./core/cache.ts"; + +// Adapters +export { serve, type BunServerOptions } from "./adapters/server-bun.ts"; +export { + createWorker, + setupWrangler, + type WorkerEnv, + type WranglerSetupOptions, +} from "./adapters/cloudflare-wrangler.ts"; +export { createHandler, setupVercel, type NodeHandler } from "./adapters/vercel-vercel.ts"; +export { + prerender, + type PrerenderOptions, + type PrerenderResult, +} from "./adapters/static.ts"; +export { predictLayout, type BreakpointAcc, type LayoutPrediction, type LayoutPredictionOptions, type ReflectiveAnchorGroup } from "./adapters/prediction.ts"; + +// Integrations +export { + checkboxMarkup, + mouseTrackerMarkup, + mouseTrackerCss, + reactCss, + reactIntegration, + reactStylesheet, + STATES, + type ReactIntegrationOptions, + type TrackerOptions, +} from "./integrations/react.ts"; +export { concentric, layoutHooks, type Utility, type UtilityContext } from "./integrations/hooks.ts"; +export { googleFonts, type GoogleFontsOptions } from "./integrations/fonts.ts"; +export { responsivityIntegration, DEFAULT_BREAKPOINTS, type ResponsivityOptions } from "./integrations/responsivity.ts"; +export { authIntegration, authFromRequest, authGuard, type AuthIdentity, type OAuthProvider, type AuthIntegrationOptions } from "./integrations/auth.ts"; + +// Testing +export { + collectTemplateTests, + readAssertions, + runTemplateTests, + type TemplateTest, + type TemplateTestFailure, + type TemplateTestOptions, + type TemplateTestRun, +} from "./views/testing.ts"; + +// Type generation +export { + generate, + readConfig, + type GeneratorOptions, + type GeneratorResult, + type ViewContract, +} from "./contracts/typegen/generator.ts"; diff --git a/integrations/auth.ts b/integrations/auth.ts new file mode 100644 index 0000000..2a98d5a --- /dev/null +++ b/integrations/auth.ts @@ -0,0 +1,95 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import type { RouteGuard } from "../core/pipeline.ts"; +import { createHash } from "node:crypto"; +import { MemorySessionStore, type SessionStore } from "../core/sessions.ts"; + +export const authGuard: RouteGuard = async (context) => { if (!context.locals.auth) { const identity = await authFromRequest(context.raw as Request); if (identity) context.locals.auth = identity; } }; + +export interface AuthIdentity { id: string; email?: string; roles?: string[] } +export interface OAuthProvider { name: string; authorizationUrl: string; clientId: string; redirectUri: string; scope?: string[]; tokenUrl: string; clientSecret?: string; userInfoUrl: string; identity(profile: Record): AuthIdentity } +export interface AuthIntegrationOptions { + login?: (email: string, password: string) => Promise | AuthIdentity | undefined; + providers?: OAuthProvider[]; + route?: string; + store?: SessionStore | ((context: IntegrationContext) => Promise | SessionStore); + sessionTtl?: number; +} + +const defaultStore = new MemorySessionStore(); +const sessionKey = (id: string) => `session:${createHash("sha256").update(id).digest("hex")}`; +async function cookie(identity: AuthIdentity, store: SessionStore, ttl: number): Promise { + const id = crypto.randomUUID(); + await store.set(sessionKey(id), { value: { identity }, expires: Date.now() + ttl * 1000 }); + return `muddle_auth=${id}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${ttl}`; +} + +/** Form and OAuth entry points. Token exchange remains provider-owned. */ +export function authIntegration(options: AuthIntegrationOptions = {}): Integration { + const route = options.route ?? "/auth"; + let store: SessionStore = defaultStore; + const ttl = options.sessionTtl ?? 3600; + return { + name: "auth", + async request(context) { const identity = await authFromRequest(context.raw as Request, store); if (identity) context.locals.auth = identity; }, + async setup(context: IntegrationContext) { + store = typeof options.store === "function" ? await options.store(context) : options.store ?? defaultStore; + context.route("GET", `${route}/login`, () => new Response('
', { headers: { "content-type": "text/html; charset=utf-8" } })); + context.route("POST", `${route}/login`, async (request, ctx) => { + if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); + if (!ctx.body || typeof ctx.body !== "object" || Array.isArray(ctx.body)) return new Response("Invalid credentials", { status: 400 }); + const data = ctx.body as Record; + const identity = await options.login?.(String(data.email ?? ""), String(data.password ?? "")); + if (request.headers.get("content-type")?.includes("application/json")) return identity ? Response.json({ user: identity }, { headers: { "set-cookie": await cookie(identity, store, ttl) } }) : Response.json({ error: "Unauthorized" }, { status: 401 }); + return identity ? new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(identity, store, ttl) } }) : new Response("Unauthorized", { status: 401 }); + }); + context.route("POST", `${route}/logout`, async (request) => { + if (request.headers.get("origin") !== new URL(request.url).origin) return new Response("Invalid origin", { status: 403 }); + const id = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; + if (id) await store.delete(sessionKey(id)); + return new Response(null, { status: 302, headers: { location: "/", "set-cookie": "muddle_auth=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax" } }); + }); + for (const provider of options.providers ?? []) { + context.route("GET", `${route}/${provider.name}`, async () => { + const state = crypto.randomUUID(); + const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"); + await store.set(`oauth:${state}`, { value: { verifier, provider: provider.name }, expires: Date.now() + 600_000 }); + const url = new URL(provider.authorizationUrl); + url.searchParams.set("client_id", provider.clientId); + url.searchParams.set("redirect_uri", provider.redirectUri); + url.searchParams.set("response_type", "code"); + url.searchParams.set("scope", (provider.scope ?? []).join(" ")); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier))).toString("base64url")); + url.searchParams.set("code_challenge_method", "S256"); + return new Response(null, { status: 302, headers: { location: url.toString(), "set-cookie": `muddle_oauth=${state}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=600` } }); + }); + context.route("GET", `${route}/${provider.name}/callback`, async (request) => { + const url = new URL(request.url); + const state = url.searchParams.get("state") ?? ""; + const bound = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_oauth=([^;]+)/)?.[1]; + if (bound !== state || !url.searchParams.get("code")) return new Response("Invalid OAuth state", { status: 400 }); + const record = await store.consume(`oauth:${state}`); + const transaction = record?.value as { provider: string; verifier: string } | undefined; + if (!transaction || record!.expires < Date.now() || transaction.provider !== provider.name) return new Response("Invalid OAuth state", { status: 400 }); + const body = new URLSearchParams({ grant_type: "authorization_code", code: url.searchParams.get("code")!, client_id: provider.clientId, redirect_uri: provider.redirectUri, code_verifier: transaction.verifier }); + if (provider.clientSecret) body.set("client_secret", provider.clientSecret); + const tokenResponse = await fetch(provider.tokenUrl, { method: "POST", body }); + if (!tokenResponse.ok) return new Response("OAuth exchange failed", { status: 502 }); + const token = await tokenResponse.json() as { access_token?: string }; + if (!token.access_token) return new Response("Missing OAuth token", { status: 502 }); + const profile = await fetch(provider.userInfoUrl, { headers: { authorization: `Bearer ${token.access_token}` } }); + if (!profile.ok) return new Response("OAuth profile failed", { status: 502 }); + return new Response(null, { status: 302, headers: { location: "/", "set-cookie": await cookie(provider.identity(await profile.json() as Record), store, ttl) } }); + }); + } + }, + }; +} + +export async function authFromRequest(request: Request, store: SessionStore = defaultStore): Promise { + const value = request.headers.get("cookie")?.match(/(?:^|;\s*)muddle_auth=([^;]+)/)?.[1]; + if (!value) return undefined; + const session = await store.get(sessionKey(value)); + if (!session || session.expires < Date.now()) { await store.delete(sessionKey(value)); return undefined; } + return session.value.identity as AuthIdentity; +} diff --git a/integrations/fonts.ts b/integrations/fonts.ts new file mode 100644 index 0000000..8c1c734 --- /dev/null +++ b/integrations/fonts.ts @@ -0,0 +1,65 @@ +import type { Cache } from "../core/cache.ts"; +import type { Integration, IntegrationContext } from "../contracts/index.ts"; + +export interface GoogleFontsOptions { + families: string[]; + route?: string; + cache?: Cache; + display?: "auto" | "block" | "swap" | "fallback" | "optional"; +} + +function memoryCache(): Cache { + const entries = new Map(); + return { + async get(key) { return entries.get(key); }, + async put(key, value) { entries.set(key, value); }, + async delete(key) { entries.delete(key); }, + }; +} + +function text(value: Uint8Array): string { + return new TextDecoder().decode(value); +} + +function googleUrl(options: GoogleFontsOptions): string { + const family = options.families.map((value) => `family=${encodeURIComponent(value.replace(/\s+/g, "+"))}`).join("&"); + return `https://fonts.googleapis.com/css2?${family}&display=${options.display ?? "swap"}`; +} + +/** Serves Google Fonts through project routes and caches both CSS and font files. */ +export function googleFonts(options: GoogleFontsOptions): Integration { + const route = options.route ?? "/_muddle/fonts.css"; + const proxyPrefix = `${route.replace(/\.css$/, "")}/`; + const cache = options.cache ?? memoryCache(); + return { + name: "google-fonts", + setup(context: IntegrationContext) { + context.route("GET", route, async () => { + const key = `css:${googleUrl(options)}`; + let css = await cache.get(key); + if (!css) { + const response = await fetch(googleUrl(options)); + if (!response.ok) return new Response("", { status: 502 }); + css = new Uint8Array(await response.arrayBuffer()); + await cache.put(key, css, { ttl: 86400 }); + } + const proxied = text(css).replace(/url\((https:\/\/[^)]+)\)/g, (_match, url: string) => `url(${proxyPrefix}${encodeURIComponent(url)})`); + return new Response(proxied, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=86400" } }); + }); + context.route("GET", `${proxyPrefix}*`, async (request) => { + const encoded = new URL(request.url).pathname.slice(proxyPrefix.length); + const url = decodeURIComponent(encoded); + if (!url.startsWith("https://fonts.gstatic.com/")) return new Response("Not Found", { status: 404 }); + const key = `font:${url}`; + let font = await cache.get(key); + if (!font) { + const response = await fetch(url); + if (!response.ok) return new Response("", { status: 502 }); + font = new Uint8Array(await response.arrayBuffer()); + await cache.put(key, font, { ttl: 31536000 }); + } + return new Response(font, { headers: { "content-type": "font/woff2", "cache-control": "public, max-age=31536000, immutable" } }); + }); + }, + }; +} diff --git a/integrations/hooks.ts b/integrations/hooks.ts new file mode 100644 index 0000000..7455d2b --- /dev/null +++ b/integrations/hooks.ts @@ -0,0 +1,48 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; + +export interface UtilityContext { + name: string; + value?: string; + selector: string; +} + +export type Utility = (context: UtilityContext) => string | undefined; + +export interface LayoutHookContext { + utilities: (name: string, utility: Utility) => void; + concentric: (name?: string) => void; +} + +export function concentric(name = "concentric"): Utility { + return ({ selector, value }) => { + const radius = value ?? "1rem"; + return `${selector} { border-radius: ${radius}; }\n${selector} > * { border-radius: inherit; }`; + }; +} + +export function layoutHooks( + hook: (context: LayoutHookContext) => void, +): Integration { + return { + name: "layout-hooks", + setup(context: IntegrationContext) { + const utilities = new Map(); + hook({ + utilities: (name, utility) => utilities.set(name, utility), + concentric: (name) => utilities.set(name ?? "concentric", concentric(name)), + }); + context.loader("utilities", { + name: "utilities", + extensions: [".utilities.css"], + load(file) { + const css = file.source.split(/\s+/).flatMap((token) => { + const [name, value] = token.split(":", 2); + const utility = utilities.get(name ?? ""); + return utility?.({ name: name ?? "", value, selector: `.${token}` }) ?? []; + }); + return { data: {}, content: css.join("\n") }; + }, + }); + }, + }; +} diff --git a/integrations/optimize.ts b/integrations/optimize.ts new file mode 100644 index 0000000..3494d30 --- /dev/null +++ b/integrations/optimize.ts @@ -0,0 +1 @@ +export * from "../modules/optimize/index.ts"; diff --git a/integrations/react.ts b/integrations/react.ts new file mode 100644 index 0000000..3498bfd --- /dev/null +++ b/integrations/react.ts @@ -0,0 +1,192 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import { compileCss } from "../views/css/index.ts"; + +/** The states the utilities know. */ +export const STATES = [ + "open", + "closed", + "active", + "disabled", + "loading", + "checked", + "selected", +] as const; + +/** The size of the grid that tracks the pointer. */ +export interface TrackerOptions { + /** Columns of the grid. */ + columns?: number; + /** Rows of the grid. */ + rows?: number; + /** Extra grid detail, multiplied into each axis. */ + detail?: number; + /** Enables the CSS grid to receive pointer events. Disabled by default. */ + capture?: boolean; + /** Stable name for a tracker mounted inside one component. */ + scope?: string; +} + +/** The list of a range, used by the loops of the stylesheet. */ +function range(count: number): string { + return Array.from({ length: count }, (_, index) => index + 1).join(" "); +} + +/** + * The stylesheet of the integration. + * + * The mouse position is tracked by a grid of cells, the parent hack moves the + * custom properties of the root element: every cell knows its position, the + * `:has()` selector of the parent picks the hovered one. + */ +export function reactStylesheet(options: TrackerOptions = {}): string { + const detail = Math.max(1, options.detail ?? 1); + const columns = (options.columns ?? 10) * detail; + const rows = (options.rows ?? 10) * detail; + const states = STATES.join(" "); + + return ` +$columns: ${columns}; +$rows: ${rows}; + +@each $state in ${states} { + .state-#{$state} { + --muddle-state: #{$state}; + } + + [data-state="#{$state}"] { + --muddle-state: #{$state}; + } +} + +[data-state] { + --muddle-state-opacity: 1; + --muddle-state-visibility: visible; +} + +[data-state="closed"] { + --muddle-state-opacity: 0; + --muddle-state-visibility: hidden; +} + +[data-state="disabled"] { + --muddle-state-opacity: 0.5; +} + +[data-state="loading"] { + --muddle-state-opacity: 0.7; +} + +.state-open { + --muddle-state-opacity: 1; + --muddle-state-visibility: visible; +} + +.state-closed { + --muddle-state-opacity: 0; + --muddle-state-visibility: hidden; +} + +.peer:checked ~ [data-state="checked"] { + display: block; +} + +.peer:not(:checked) ~ [data-state="unchecked"] { + display: none; +} + +`; +} + +/** CSS for one explicitly mounted tracker. */ +export function mouseTrackerCss(options: TrackerOptions = {}): string { + const detail = Math.max(1, options.detail ?? 1); + const columns = (options.columns ?? 10) * detail; + const rows = (options.rows ?? 10) * detail; + const scope = options.scope ?? "default"; + const selector = `[data-muddle-tracker="${scope}"]`; + const rules = Array.from({ length: columns * rows }, (_, index) => { + const x = Math.floor(index / rows) + 1; + const y = (index % rows) + 1; + return `${selector}:has(.mouse-tracker__cell:nth-child(${index + 1}):hover) { --muddle-mouse-x: ${x / columns * 100}%; --muddle-mouse-y: ${y / rows * 100}%; }`; + }); + return `${selector} { position: relative; --muddle-mouse-x: 50%; --muddle-mouse-y: 50%; }\n${selector} > .mouse-tracker { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(${columns}, 1fr); grid-template-rows: repeat(${rows}, 1fr); pointer-events: none; }\n${selector}[data-muddle-capture="true"] > .mouse-tracker .mouse-tracker__cell { pointer-events: auto; }\n${rules.join("\n")}`; +} + +/** The compiled stylesheet of the integration. */ +export function reactCss(options: TrackerOptions = {}): string { + return compileCss(reactStylesheet(options)); +} + +/** The markup of the grid that tracks the pointer. */ +export function mouseTrackerMarkup(options: TrackerOptions = {}): string { + const detail = Math.max(1, options.detail ?? 1); + const columns = (options.columns ?? 10) * detail; + const rows = (options.rows ?? 10) * detail; + const cells = Array.from( + { length: columns * rows }, + () => '
', + ).join(""); + const scope = options.scope ?? "default"; + return `
`; +} + +/** The markup of a checkbox, the label is the control. */ +export function checkboxMarkup(options: { + name: string; + label: string; + value?: string; + checked?: boolean; + disabled?: boolean; + class?: string; +}): string { + const attributes = [ + `type="checkbox"`, + `name="${options.name}"`, + options.value ? `value="${options.value}"` : "", + options.checked ? "checked" : "", + options.disabled ? "disabled" : "", + 'class="checkbox__input"', + ] + .filter((entry) => entry.length > 0) + .join(" "); + return ``; +} + +export interface ReactIntegrationOptions extends TrackerOptions { + /** Route the stylesheet is served at. */ + route?: string; +} + +/** + * Reactivity integration. + */ +export function reactIntegration( + options: ReactIntegrationOptions = {}, +): Integration { + const route = options.route ?? "/_muddle/react.css"; + + return { + name: "react", + setup(context: IntegrationContext) { + context.loader("react", { + name: "react", + extensions: [".react.css"], + load(file) { + return { + data: {}, + content: compileCss(`${file.source}\n${reactStylesheet(options)}`), + }; + }, + }); + + context.route("GET", route, () => + new Response(reactCss(options), { + headers: { + "content-type": "text/css; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }), + ); + }, + }; +} diff --git a/integrations/responsivity.ts b/integrations/responsivity.ts new file mode 100644 index 0000000..caf82fa --- /dev/null +++ b/integrations/responsivity.ts @@ -0,0 +1,24 @@ +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import { compileCss } from "../views/css/index.ts"; + +export interface ResponsivityOptions { + breakpoints?: Record; + route?: string; +} + +export const DEFAULT_BREAKPOINTS = { sm: "40rem", md: "48rem", lg: "64rem", xl: "80rem" }; + +/** Adds mobile-first `@responsive name {}` blocks to STCSS. */ +export function responsivityIntegration(options: ResponsivityOptions = {}): Integration { + const breakpoints = { ...DEFAULT_BREAKPOINTS, ...options.breakpoints }; + return { + name: "responsivity", + setup(context: IntegrationContext) { + context.loader("responsive-stcss", { + name: "responsive-stcss", + extensions: [".responsive.stcss"], + load(file) { return { data: { breakpoints }, content: compileCss(file.source, { breakpoints }) }; }, + }); + }, + }; +} diff --git a/integrations/shadcn.ts b/integrations/shadcn.ts new file mode 100644 index 0000000..1817e03 --- /dev/null +++ b/integrations/shadcn.ts @@ -0,0 +1,60 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, extname, join, resolve } from "node:path"; +import type { Integration, IntegrationContext } from "../contracts/index.ts"; +import { cssToStcss, reactToSpug } from "../core/backport.ts"; + +export interface ShadcnIntegrationOptions { + /** Registry component names, for example `button`, `card`, or `dialog`. */ + components: string[]; + /** Registry origin or `{name}` URL template. Defaults to ShadCN's New York registry. */ + registry?: string; + /** Destination relative to the Muddle project root. */ + dir?: string; + /** Injectable for offline registries and tests. */ + fetch?: (input: string) => Promise; +} + +interface RegistryItem { + files?: { path: string; content: string; type?: string }[]; + registryDependencies?: string[]; +} + +/** + * Pulls declaratively selected ShadCN React components and backports them + * into SPUG and STCSS during application setup. + */ +export function shadcnIntegration(options: ShadcnIntegrationOptions): Integration { + return { + name: "shadcn", + async setup(context: IntegrationContext) { + const fetcher = options.fetch ?? globalThis.fetch; + const destination = resolve(context.config.root, options.dir ?? "modules/app/views/components"); + const styles = resolve(context.config.root, "styles/shadcn"); + const seen = new Set(); + const pull = async (name: string): Promise => { + if (seen.has(name)) return; + seen.add(name); + const base = options.registry ?? "https://ui.shadcn.com/r/styles/new-york/{name}.json"; + const url = base.includes("{name}") + ? base.replace("{name}", encodeURIComponent(name)) + : `${base.replace(/\/$/, "")}/${encodeURIComponent(name)}.json`; + const response = await fetcher(url); + if (!response.ok) throw new Error(`shadcn registry request failed for ${name} with ${response.status}`); + const item = await response.json() as RegistryItem; + for (const dependency of item.registryDependencies ?? []) await pull(dependency.replace(/^@[^/]+\//, "")); + for (const file of item.files ?? []) { + const extension = extname(file.path).toLowerCase(); + const stem = basename(file.path).replace(/\.(tsx|jsx|css)$/i, ""); + if (extension === ".tsx" || extension === ".jsx") { + await mkdir(destination, { recursive: true }); + await writeFile(join(destination, `${stem}.spug`), reactToSpug(file.content, file.path)); + } else if (extension === ".css") { + await mkdir(styles, { recursive: true }); + await writeFile(join(styles, `${stem}.stcss`), cssToStcss(file.content, file.path)); + } + } + }; + for (const component of options.components) await pull(component); + }, + }; +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..ec5a3c2 --- /dev/null +++ b/justfile @@ -0,0 +1,31 @@ +set dotenv-load +set positional-arguments + +default: + @just --list + +test: + bun test ./tests + +loc: + @echo "Loc: counting source lines in the codebase..." + @find . \ + \( -name '*.ts' -o -name '*.scss' -o -name '*.sass' -o -name '*.md' \ + -o -name '*.html' -o -name '*.toml' -o -name '*.js' -o -name '*.css' \) \ + -not -path '*/node_modules/*' \ + -not -path './editors/vscode/node_modules/*' \ + -not -path './dist/*' \ + -not -path './.git/*' \ + -not -path '*/bun.lock' \ + -not -path '*/.git/*' \ + -not -path '*/.cache/*' \ + -not -path '*/.next/*' \ + -not -path '*/dist/*' \ + -not -path '*/.vscode/*' \ + -not -path '*/.idea/*' \ + -not -path '*/ametrine/*' \ + -not -path '*/tests/*' \ + -not -path '*/README.md' \ + -not -path './modules/amezing/*' \ + -not -path './.opencode/*' \ + | xargs wc -l | tail -n +2 \ No newline at end of file diff --git a/modules/amezing/README.md b/modules/amezing/README.md new file mode 100644 index 0000000..76e888b --- /dev/null +++ b/modules/amezing/README.md @@ -0,0 +1,3 @@ +# @muddle/amezing + +Port of the Ametrine theme (by Daudix) as a muddle module. \ No newline at end of file diff --git a/modules/amezing/components/alert.spug b/modules/amezing/components/alert.spug new file mode 100644 index 0000000..40b4184 --- /dev/null +++ b/modules/amezing/components/alert.spug @@ -0,0 +1,8 @@ +props + type: 'note' | 'tip' | 'important' | 'warning' | 'caution' + content?: string | (() => string | Promise) +blockquote(class='markdown-alert markdown-alert-' + type) + if typeof content === 'function' + != await content() + else + = content ?? '' diff --git a/modules/amezing/components/article-list.spug b/modules/amezing/components/article-list.spug new file mode 100644 index 0000000..01c62d9 --- /dev/null +++ b/modules/amezing/components/article-list.spug @@ -0,0 +1,29 @@ +props + pages: { title: string; href: string; description?: string; date?: string; updated?: string; banner?: string; bannerStyle?: string; articleClass?: string; draft?: boolean; categories?: { name: string; href: string; description?: string }[] }[] +div.article-list + each page in pages + article(class=page.articleClass style=page.bannerStyle) + if page.banner + a.open-post(href=page.href aria-label=page.title) + img.has-alpha.no-hover(src=page.banner decoding="async" loading="lazy") + div + a(href=page.href) + h3= page.title + if page.description + div.description + != page.description + div.article-details(data-slot="article-details") + div.items + if page.date + div.item + i.ph.ph-calendar(aria-hidden="true") + time(datetime=page.date pubdate)= page.date + if page.updated + div.item + i.ph.ph-clock-clockwise(aria-hidden="true") + time(datetime=page.updated pubdate)= page.updated + if page.categories + div.item + i.ph.ph-book-bookmark(aria-hidden="true") + each category in page.categories + a(href=category.href title=category.description)= category.name diff --git a/modules/amezing/components/article-nav.spug b/modules/amezing/components/article-nav.spug new file mode 100644 index 0000000..295512e --- /dev/null +++ b/modules/amezing/components/article-nav.spug @@ -0,0 +1,14 @@ +props + previousHref?: string + previousTitle?: string + nextHref?: string + nextTitle?: string +nav.article-nav + if previousHref + a.article-nav-item.prev(href=previousHref) + div.nav-arrow Previous + span.article-title= previousTitle + if nextHref + a.article-nav-item.next(href=nextHref) + div.nav-arrow Next + span.article-title= nextTitle diff --git a/modules/amezing/components/article.spug b/modules/amezing/components/article.spug new file mode 100644 index 0000000..083afb9 --- /dev/null +++ b/modules/amezing/components/article.spug @@ -0,0 +1,31 @@ +props + title: string + description?: string +article + h1 + = title + div.article-details(data-slot="article-details") + div.items + if date + div.item + i.ph.ph-calendar(aria-hidden="true") + time(datetime=isoDate pubdate)= date + if updated + div.item + i.ph.ph-clock-clockwise(aria-hidden="true") + time(datetime=isoUpdated pubdate)= updated + if authors + div.item + i.ph.ph-users(aria-hidden="true") + span= authors + if readingTime + div.item + i.ph.ph-clock-countdown(aria-hidden="true") + span= readingTime + ' min read' + if body + div.article-body + != body + if notes + ul.statements + each statement in notes + li= statement diff --git a/modules/amezing/components/audio-button.spug b/modules/amezing/components/audio-button.spug new file mode 100644 index 0000000..4980a7c --- /dev/null +++ b/modules/amezing/components/audio-button.spug @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/modules/amezing/components/audio.spug b/modules/amezing/components/audio.spug new file mode 100644 index 0000000..1c0abae --- /dev/null +++ b/modules/amezing/components/audio.spug @@ -0,0 +1,7 @@ +props + url: string + name?: string +button.audio(type="button" data-audio=url) + if name + span= name + i.ph.ph-speaker-high(aria-hidden="true") diff --git a/modules/amezing/components/banner.spug b/modules/amezing/components/banner.spug new file mode 100644 index 0000000..91a2761 --- /dev/null +++ b/modules/amezing/components/banner.spug @@ -0,0 +1,7 @@ +props + src: string + alt?: string + bannerPixels?: boolean +div#banner(data-slot="banner" class=bannerPixels ? 'pixels' : '') + div + img.has-alpha.no-hover(src=src decoding="async" loading="lazy" alt=alt ?? '') diff --git a/modules/amezing/components/base.spug b/modules/amezing/components/base.spug new file mode 100644 index 0000000..27527e4 --- /dev/null +++ b/modules/amezing/components/base.spug @@ -0,0 +1,133 @@ +doctype html +html(lang="en" style=accentStyle ?? '') + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + meta(name="description" content=description ?? '') + title= title + link(rel="stylesheet" href="/_muddle/amezing.css") + link(rel="stylesheet" href="/_muddle/styles.css") + link(rel="stylesheet" href="https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/style.css") + body + a#skip-to-content.link(href="#main-content" tabindex="0") Skip to content + header#site-header.firefox-unblur + ul#site-sidebar-controls + li#expand-sidebar-li + input#expand-sidebar.visually-hidden(type="checkbox" name="expand-sidebar" autocomplete="off") + label(for="expand-sidebar" title="Toggle sidebar") + i.ph.ph-sidebar(aria-hidden="true") + ul#site-sidebar-modes(role="radiogroup") + li + input#mode-nav.visually-hidden(type="radio" name="mode" checked) + label(for="mode-nav" title="Navigation mode") + i.ph.ph-compass(aria-hidden="true") + li + input#mode-meta.visually-hidden(type="radio" name="mode") + label(for="mode-meta" title="Meta mode") + i.ph.ph-info(aria-hidden="true") + span.title= title + ul.buttons + li + details.closable + summary(title="Feed") + i.ph.ph-rss-simple(aria-hidden="true") + ul.dropdown + li + a(href="/atom.xml") Atom + li + a(href="/rss.xml") RSS + if searchHref + li + button#search(title="Search" aria-haspopup="dialog") + i.ph.ph-magnifying-glass(aria-hidden="true") + div#site-sidebar + a#site-sidebar-header(href="#top" class=logo ? 'has-icon' : '') + if logo + img(width="36" height="36" class="has-alpha no-hover" src=logo alt="") + else + i.ph.ph-arrow-line-up(aria-hidden="true") + div + span= siteTitle ?? title + span Back to top + div#site-sidebar-content + nav#sidebar-nav.overshoot + ul + li + a(href="/" aria-current=(currentPath ?? '/') === '/' ? 'page' : '') + i.ph.ph-house(aria-hidden="true") + = siteTitle ?? title + each entry in nav + if entry.children + li + details.closable(open=entry.open) + summary + = entry.name + div.line + ul.dropdown + each child in entry.children + li + a(href=child.href aria-current=child.active ? 'page' : '' class=child.external ? 'external' : '') + if child.icon + i.ph.ph-(child.icon)(aria-hidden="true") + if child.label + span= child.label + else + span= child.name + else + li + a(href=entry.href aria-current=entry.active ? 'page' : '' class=(entry.external ?? entry.href.indexOf('http') === 0) ? 'external' : '') + if entry.icon + i.ph.ph-(entry.icon)(aria-hidden="true") + if entry.label + span= entry.label + else + span= entry.name + div#sidebar-meta.overshoot + if metaTitle ?? note + div.card + div.card-title + div.line + span.title= metaTitle ?? 'Meta' + div.line + if note + div.note= note + main#main-content + if content + != await content() + footer#site-footer + div + if copyright + p.copyright= copyright + if poweredBy ?? undefined + p= 'Powered by ' + a.link.external(href="https://www.getzola.org") Zola + = ' and ' + a.link.external(href="https://ametrine.daudix.one") Ametrine + if sourceHref ?? undefined + p + a.link.external(href=sourceHref) Source + = ' | Updated ' + timestamp + ' UTC' + if socials + ul.socials + each social in socials + li + a(href=social.href rel="me external") + i.ph.ph-(social.name)(aria-hidden="true") + if webrings + ul.webrings + each webring in webrings + li.webring + a.link.external(href=webring.href)= webring.name + a.link.previous(href=webring.previous title="Previous") + i.ph.ph-arrow-left + if webring.random + a.link.random(href=webring.random title="Random") + i.ph.ph-dice-three + a.link.next(href=webring.next title="Next") + i.ph.ph-arrow-right + if note + div.note= note + script(src="/_muddle/amezing/closable.js" defer) + script(src="/_muddle/amezing/paginator.js" defer) + script(src="/_muddle/amezing/deanchor.js" defer) + script(src="/_muddle/amezing/marquee.js" defer) diff --git a/modules/amezing/components/comments.spug b/modules/amezing/components/comments.spug new file mode 100644 index 0000000..6ac2b62 --- /dev/null +++ b/modules/amezing/components/comments.spug @@ -0,0 +1,32 @@ +{%- set alert_icon = load_data(path="icons/phosphor/" ~ icon ~ ".svg", required=false) -%} +{%- if not alert_icon -%} + {%- set alert_icon = "info" -%} +{%- endif -%} +
+ +
+ {%- set comments = load_data(path="data/comments.toml", required=false) -%} + {%- if comments -%} + {%- for comment in comments -%} +
+
+ {{ comment.author }} + {{ comment.date }} +
+

{{ comment.content }}

+
+ {%- endfor -%} + {%- else -%} +
+
💭
+

No comments yet. Be the first to share your thoughts!

+
+ {%- endif -%} +
+
\ No newline at end of file diff --git a/modules/amezing/components/crt.spug b/modules/amezing/components/crt.spug new file mode 100644 index 0000000..4f17fdd --- /dev/null +++ b/modules/amezing/components/crt.spug @@ -0,0 +1,11 @@ +props + fontSize?: string + label?: string + content?: string | (() => string | Promise) +pre.crt(style=fontSize ? 'font-size: ' + fontSize + ';' : '' role=label ? 'img' : false aria-label=label ? label : false) + code(aria-hidden=label ? false : true) + span + if typeof content === 'function' + != await content() + else + != content ?? '' diff --git a/modules/amezing/components/deanchor.spug b/modules/amezing/components/deanchor.spug new file mode 100644 index 0000000..0fea7f5 --- /dev/null +++ b/modules/amezing/components/deanchor.spug @@ -0,0 +1 @@ +{{ text | striptags }} \ No newline at end of file diff --git a/modules/amezing/components/emoji.spug b/modules/amezing/components/emoji.spug new file mode 100644 index 0000000..f25fe29 --- /dev/null +++ b/modules/amezing/components/emoji.spug @@ -0,0 +1,5 @@ +props + path: string + big?: boolean + title?: string +img.emoji(class=big ? 'big' : false src=path title=title ?? '' width="24" height="24") diff --git a/modules/amezing/components/footnotes.spug b/modules/amezing/components/footnotes.spug new file mode 100644 index 0000000..a99881a --- /dev/null +++ b/modules/amezing/components/footnotes.spug @@ -0,0 +1,15 @@ +
+ {%- set footnotes = load_data(path="data/footnotes.toml", required=false) -%} + {%- if footnotes -%} +
+ {%- for footnote in footnotes -%} +
+ + {{ loop.index }} + +

{{ footnote.content }}

+
+ {%- endfor -%} +
+ {%- endif -%} +
\ No newline at end of file diff --git a/modules/amezing/components/fuse.spug b/modules/amezing/components/fuse.spug new file mode 100644 index 0000000..7f6c9f0 --- /dev/null +++ b/modules/amezing/components/fuse.spug @@ -0,0 +1,4 @@ +
+ +
+
\ No newline at end of file diff --git a/modules/amezing/components/icon.spug b/modules/amezing/components/icon.spug new file mode 100644 index 0000000..c9414cb --- /dev/null +++ b/modules/amezing/components/icon.spug @@ -0,0 +1,3 @@ +props + name: string +i.ph.ph-(name)(aria-hidden="true") diff --git a/modules/amezing/components/image.spug b/modules/amezing/components/image.spug new file mode 100644 index 0000000..268960b --- /dev/null +++ b/modules/amezing/components/image.spug @@ -0,0 +1,22 @@ +props + src: string + alt?: string + url?: string + caption?: string + width?: number + height?: number + lazy?: boolean +if caption + figure + if url + a(href=url) + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) + else + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) + figcaption!= caption +else + if url + a(href=url) + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) + else + img(src=src alt=alt ?? '' decoding=lazy ? 'async' : false loading=lazy ? 'lazy' : false) diff --git a/modules/amezing/components/link.spug b/modules/amezing/components/link.spug new file mode 100644 index 0000000..5e59bbb --- /dev/null +++ b/modules/amezing/components/link.spug @@ -0,0 +1,14 @@ +props + name: string + href: string + icon?: string + external?: boolean + active?: boolean + afterActive?: boolean +li + a(href=href + (!external && !active && afterActive ? '#↓' : !external && !active ? '#↑' : '') aria-current=active ? 'page' : false class=external ? 'external' : false) + if icon + i.ph.ph-(icon)(aria-hidden="true") + else + i.icon.fallback= name.slice(0, 2) + = name diff --git a/modules/amezing/components/mastodon.spug b/modules/amezing/components/mastodon.spug new file mode 100644 index 0000000..09b7636 --- /dev/null +++ b/modules/amezing/components/mastodon.spug @@ -0,0 +1,5 @@ +props + host: string + user: string + id: string +iframe.mastodon-embed(src='https://' + host + '/@' + user + '/' + id + '/embed') diff --git a/modules/amezing/components/overflow-auto.spug b/modules/amezing/components/overflow-auto.spug new file mode 100644 index 0000000..3667d16 --- /dev/null +++ b/modules/amezing/components/overflow-auto.spug @@ -0,0 +1,9 @@ +props + overshoot?: boolean + overshootRow?: boolean + content?: string | (() => string | Promise) +div.overflow-auto(class=overshoot ? 'overshoot' : false class=overshootRow ? 'overshoot-row' : false) + if typeof content === 'function' + != await content() + else + != content ?? '' diff --git a/modules/amezing/components/paginator.spug b/modules/amezing/components/paginator.spug new file mode 100644 index 0000000..ead0a48 --- /dev/null +++ b/modules/amezing/components/paginator.spug @@ -0,0 +1,32 @@ +props + counter: string + first?: string + previous?: string + next?: string + last?: string +nav.paginator + if first + a.paginator-item.first(href=first title="First page") + i.ph.ph-caret-line-left(aria-hidden="true") + else + span.paginator-item.first(aria-disabled="true") + i.ph.ph-caret-line-left(aria-hidden="true") + if previous + a.paginator-item.previous(href=previous rel="prev" title="Previous page") + i.ph.ph-caret-left(aria-hidden="true") + else + span.paginator-item.previous(aria-disabled="true") + i.ph.ph-caret-left(aria-hidden="true") + span.paginator-counter= counter + if next + a.paginator-item.next(href=next rel="next" title="Next page") + i.ph.ph-caret-right(aria-hidden="true") + else + span.paginator-item.next(aria-disabled="true") + i.ph.ph-caret-right(aria-hidden="true") + if last + a.paginator-item.last(href=last title="Last page") + i.ph.ph-caret-line-right(aria-hidden="true") + else + span.paginator-item.last(aria-disabled="true") + i.ph.ph-caret-line-right(aria-hidden="true") diff --git a/modules/amezing/components/search-fuse.spug b/modules/amezing/components/search-fuse.spug new file mode 100644 index 0000000..5c1bf7e --- /dev/null +++ b/modules/amezing/components/search-fuse.spug @@ -0,0 +1,10 @@ +
+
+ + + + + +
+
+
\ No newline at end of file diff --git a/modules/amezing/components/search-modal.spug b/modules/amezing/components/search-modal.spug new file mode 100644 index 0000000..1a34e1e --- /dev/null +++ b/modules/amezing/components/search-modal.spug @@ -0,0 +1,11 @@ +props + indexHref?: string +dialog#search-modal(data-slot="search-modal" closed ? aria-hidden="true") + div#search-modal-content + label(for="search-input" class="visually-hidden") Search + div + input#search-input(placeholder="Search" autocomplete="off" type="search") + div(title="Press / to search") + i.ph.ph-keyboard(aria-hidden="true") + div#search-results.overshoot + span#more-matches-text(hidden) More matches diff --git a/modules/amezing/components/skip-to-content.spug b/modules/amezing/components/skip-to-content.spug new file mode 100644 index 0000000..ffde958 --- /dev/null +++ b/modules/amezing/components/skip-to-content.spug @@ -0,0 +1,4 @@ + + {%- set icon = load_data(path="icons/phosphor/" ~ "arrow-up-right" ~ ".svg", required=false) -%} + {%- if icon -%}{{ icon | safe }}{{ else }}Skip to content{{ end }} + \ No newline at end of file diff --git a/modules/amezing/components/statement.spug b/modules/amezing/components/statement.spug new file mode 100644 index 0000000..f72e5c9 --- /dev/null +++ b/modules/amezing/components/statement.spug @@ -0,0 +1,11 @@ +props + kind: 'archive' | 'trigger' | 'disclaimer' + content?: string | (() => string | Promise) +div.statement-container(class=kind) + strong.title + i.ph.ph-(kind === 'archive' ? 'box-arrow-down' : 'warning')(aria-hidden="true") + span= kind === 'archive' ? 'Archived' : kind === 'trigger' ? 'Trigger warning' : 'Disclaimer' + if typeof content === 'function' + != await content() + else + != content ?? '' diff --git a/modules/amezing/components/tag-chips.spug b/modules/amezing/components/tag-chips.spug new file mode 100644 index 0000000..3a99494 --- /dev/null +++ b/modules/amezing/components/tag-chips.spug @@ -0,0 +1,9 @@ +props + items: { name: string; href: string; count?: number }[] +ul#tag-chips + each item in items + li + a(href=item.href) + = item.name + if item.count + span.counter= item.count diff --git a/modules/amezing/components/video.spug b/modules/amezing/components/video.spug new file mode 100644 index 0000000..b0de71f --- /dev/null +++ b/modules/amezing/components/video.spug @@ -0,0 +1,21 @@ +props + sources: { src: string, type?: string }[] + caption?: string + width?: number + height?: number + autoplay?: boolean + controls?: boolean + loop?: boolean + muted?: boolean + playsinline?: boolean + alt?: string +if caption + figure + video(preload="auto" aria-title=alt ?? '' width=width ?? false height=height ?? false autoplay=autoplay ?? false controls=controls ?? true loop=loop ?? false muted=muted ?? false playsinline=playsinline ?? false) + each source in sources + source(src=source.src type='video/' + (source.type ?? source.src.split('.').pop())) + figcaption!= caption +else + video(preload="auto" aria-title=alt ?? '' width=width ?? false height=height ?? false autoplay=autoplay ?? false controls=controls ?? true loop=loop ?? false muted=muted ?? false playsinline=playsinline ?? false) + each source in sources + source(src=source.src type='video/' + (source.type ?? source.src.split('.').pop())) diff --git a/modules/amezing/components/vimeo.spug b/modules/amezing/components/vimeo.spug new file mode 100644 index 0000000..b8befb8 --- /dev/null +++ b/modules/amezing/components/vimeo.spug @@ -0,0 +1,4 @@ +props + id: string + autoplay?: boolean +iframe.vimeo-embed(src='https://player.vimeo.com/video/' + id + (autoplay ? '?autoplay=1' : '') allow="autoplay; fullscreen; picture-in-picture" allowfullscreen=true) diff --git a/modules/amezing/components/youtube.spug b/modules/amezing/components/youtube.spug new file mode 100644 index 0000000..909cc1e --- /dev/null +++ b/modules/amezing/components/youtube.spug @@ -0,0 +1,5 @@ +props + id: string + autoplay?: boolean + start?: number +iframe.youtube-embed(src='https://www.youtube-nocookie.com/embed/' + id + (autoplay && start ? '?autoplay=1&start=' + start : autoplay ? '?autoplay=1' : start ? '?start=' + start : '') allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen=true) diff --git a/modules/amezing/fonts/commissioner.woff2 b/modules/amezing/fonts/commissioner.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f02d31bfbcb668da67d55cd3fcfe242c8588ce83 GIT binary patch literal 268392 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%0AKi4piGX`fRj3SK3R`e* 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°JfWIH%+S3nrpCxxcZ+V}BWq@$^Nbq<65`+~jbc)1 zn#tnPKHbDjM6B+gc8|jXUiHV-xKFmeP`BUsr$bCpI?rmR_B+ELdi40=&aM^~>_5kI z46#Uc+;_K&lv+Y?K}mC@x;uPOO>y$4azo&zfdp{3dB7k53r_-`RLyNA9&M&nrwFapS(37C#go8`&G8a#=Ag$6 zqiPTvfLGVsRhWpsIO#3)JUQY^P-lnP^UZ`>3}okXn25zJoS^_2&rkYvAD55599|kl zgIgP(Zzk5YK&$%x@`_-va@(TlqAz?c?89ioE?(<8)EH|48;)s0( zKAZ?KROMnbp$U!EFy;V&Tgf(YvY9ASU2p8@A!9wSkI%3yH?5|*9C17<&hDhMS{@6sM^n@zAyfkv2<_mUgxgU*Z$jr=@`#gdeo0y zY-DQZjOqVty3SnpZmo|blhX0b`YS8w*kE%t&jg4f03{IQUb&5+%a_sT&(FuKtd3U7 z{`Sj1TR(jfd_4+Q=P@!uI#!-iOZD8(&Ff^;@4Rj7?- zO2lYGI$ewC&1Wi`(+OFx#-3Gwahrb_6ZCh#n%F2*zir~VIVOJzVpLZ;4VL-2or)t3 zm*4(Yz+j~7mrHDfePH0`g0Q+B+9k^=$HKjoJ{YSRWp6O^}?FhyuwF?m1QSp4@6P>QE5e!6C@~u zY=XLE4eC|ElVKM_f999^S=XludZZ!STj z|3b$~L!$&(T$>x3O@YwFt<8c_#QFr5vUfh;)$1R93AoD)04S0Xh$6WHC}uf)`Gd<< zU7SlSHPe%rn@Oiro=@$wTp{%}Td!bvsMRLFsf6Af*Sia=KwT?O!}MjEcv6hU`*D^6L``J;Wt8XUX#$i-qDaAL zlwK{~Y{&aLbJ%lm@okqBRdj1Xq^=>B$AUy@BGT$r0{e67D$i^@JjAq?f&iaChVlp2 zrjE6q#-L!Rn0Z6@OKz(LO!|K8bIR)QtvwczRgJnbO_5ER?X#)mHed8{y4J=vM@|Z= zxO!xycGVR%-O5EjFk*~rN%_|YDn$8%pu{+@HN8FO(Q?Q}v0{8?Qf+?y!3+vx%r8+% z51Py(_VqTIfl8y*45eIMhcgw2D@dvWqT<=rkiwG%>?O1@lc){J)H%_`!h+W%z!BHJ7Inyttg>`LQtOm;&D(NjMe) zfQRkgA8gR5s?#;N(iHrW0VOf5lW^vEk30{CY3$dc9bSk`BTttnQ^WE z@E?#=_nHl-hd_`U;i!;O0hL_3`CZ>z)RyXgANXI!nogN_Gr9P2HA+b*vQqzj{>M4? zx-~tmYf_9V%0l1=Peb4OO(>;MMDX?oo=F=}hLoP%S{%v$PAugXwekS`HPluG=z7{% z`LS@z^XA<)|Ka&f@%hAO#+twIa$El=-+AUn`VT++Kl)3riw4H+8hGCG)}3d!l7t^s zm~kzZW}~yVyS@XCKW(dR&yotoYjma2OYlH<6X_+>4z^~<5JAQa2#(G66wq)r3$jf= z$bkZj&u^Guoyu$y&zq0+Qn8>I2gf|qNxr&%<8K1oc3ZS1Q`b6dAo~P(EnBl~gNNqfv8K zle+dhbmW*S7Gn>u1u{7UPW1h$fRXAC<(Ll+&AiZ?;_|wWiP=s0sH|7Q$f&zz(7Up~ z7?iS%PR(+7@SyA*>@A|8)cJg8MmyxZD6RflLy&Ttjp|o5&!0{P5sXb(he_QLY#VgH z4-cY|6;6NyjOfPk5Dk02P}gCviZ{*hvaq+Q;?$>yS;SIF_wkN30WN}zba|SVx3&ei zj3$js9Sd(y<3I)2*od4l9?En5xl!f~V0(8rEmuglFaBE*j-iPmu4 zfUvsQd#w-b!Bsyzb@a|Id8=F*a#B6 zEMr>*r%lOF8x)$!rI^aI!$@Z2#K|3cGJX5d;Um9?eoUr;`^e*W$DUkSQvWt8*%)_fqMp6aLg&IGCS!fR3dU_;-m!Y*`$@?<$A`d`1`k zhXhE z=rn>{k}&~8(^UiJTVK4>PY;rM!@wP1K%~kYyt)R|yzHd-e9{+>&A7%})qDnO-tj`t zoONGQ$C*;QDm$X5OQ%<1?Gu`3+R~Q?cMQ7rVsx>4j*Kmx?cy)U8ROS{w`_Fh;~(=W z{LXtwk#^m5qCGUDO1D}q5d{KbPfZpG#0H?^)Q++JFoPH*mrsZs{1t;865CEnKm@@r zCxw*kV;-)UyE?;n|G+cSD+?1jks9do=tp<9*IcTAR1I1S>*gzowkzL zn8^HVC>PtVgcY=ew$ZK_6cFo3sHuK=Iu*m#_J`T&ozd2?oU+nV-J%;-wb>{RT3A1& z9zrM8HbN$C2b4k@zg-#f<`IQ8M~$r=(OD!#C$BRjZQ9_C$m z^Qu!Dp_Iw6seQu1jJQAj%+5g6INJNnt*50t$Y*H!TU|V7nZAbWhGk>N*^EILC|BFu zFYZT^-w=&t+P&lBfV+@9j3d5(JzIj-{#bI<)U`^fzo3+wv9B6#i+G$zmm@!zesn+n zwe9taN^RERrXU`l!Mg_gDg!Tsn&TW3UDi7T!Zbibne+FNkE`taUPPLjhbt0M9o zXH3WzwK+($w2-75`l-i>nRCCRs20^a-zdi&hy=_5R<&Lq2W;I{UZUq5#pbn- z%0*+ClqmDNWe|u=P4iQ?mFu?l^%V24YsWa5t=pvmfIj@Fan9cI! zj6#jodwJ-a(^|VXGvTz3_c)hTM0ZYa80!)d3>(#qIrwZ3UU*3+v_%SUqhNwUPP|b4 z4)Kc0qU7c&%Vh~E&=-hO7^n|qiEh5C9@BcvPk~f^)il)6=I_Gt!(VLh?RRPO{?{bu zx#mHeU6yurPOk^e=^)uvwp1)ko&$HzqREUDBIcOqw&H{7AAgkF`yc+|JYG`dFJsL2 z_wNpZ$)D}?Z~5nQ))|_lrQxIW(*f>5(^Ugq97lNwO@BMPc?3w{s~- z&{7O_j46Q>WaC*eyO{f~xB8HYn_DDOLbeG?D6LATs}i+vbJa9YJ}Z0MI&PJqf!hbj z?+m14)Qc%>jRHR;Ij>suk9>enOuwIPjjU9Xgc663(=gwi1Mzg^8F)OQHmX8BK z^A878NPgVa3ok<-*flxchh#DL_R`&|iHEO)S!^D5v#I&=CJ?3v%r3!iJ@>0WRUNKE z+qDgcdPX|?v7n>C;lhhTeK-#o;MrPIR4LZXe`sXGMN8I5Z&^?Q)NWAyw}Nu_FfUx8 z4#@{kII#1efZILtFI^iskM63a@6}aV@9Bemaw`)!h^s_Z{a@kE_-pmL5(g7eC|;sQ zl&{07@ek@CJlpfLrW*N}gMtxkI;rdiFe}?wiF#GCen~v~+7`Wa%+*%`_=XF5CLy*; z?1{4dM*f!9=>vM$V_2vbaMbVLGqWI#0lPxBMA zwbzO6RXV7)WPy{C)s7n?nnu#GzUfSy--EMxmRE=zz zd}hy#(G(n`>4+^?alpMq;;8P9Sk3<|FrO(?b+O2K1NNA$*Kbr>m7MASR?pC&II1;#8| zG>2%B*OO>#=W$pALSQ(5sOtf9tI*E+KnTgu_o1q;nr3m)t920AbSz{CTYDXAQVYNa6S)@=aP#30qJF!{&TOV!SokX@^ClMb@YIcS37$FbdZ*?CXvmF+dv4 zK^odb@&PdkLFtl(#T*<*Z&Qz7%2YnjDL5am7xu*sNb6<D+QgBFx8zL4;WjgovV^_?o&z}nxfATpMV-D57( z$ULy;LnvAfE*{b2aKO4=!}k@e537BxW8Yg9`__G6`RGJ!H_J!9Z^z_`JX#kpWBLkc z)^*hia^lJ_778O&=}or7sC&pSF@-wzs$!htYGVKl=++4+YGH?e-8k;_^=d*)yL!~a zqFES1@Q%SA07-nT#2P`e4+oijovC~KvDv7x)eICp!2jMV;b?%Fq1}xoJ2S7dE7G=a zsvCu~w)W*L@WPQj2yXY)`Vd%qXS3{$$Eq!1x4SD)60A?V$Dc-Nt---heDLTKU>ZBh z)K6&eRhG=xQmaI9d0*4A>o8Y6&Qf8zH&uHYZ!j(~WiZOq?qLR1Yd}#ffePWp$%$8s zV!bcB)(xW&CYM^Yt5t*N6mGuW>c@RA>Wjf`P_}>&@Yt)mHgakub$+HIm8pf&S)uBwvq*n4@2g?mYBgtA1WqGtH86B3J#J++MJ$j)NF`*&t;#l z?Z3~WISU1p4qisW#;;e0N*^oWLsUtja0A?6e68)OF4+>Q(XsgxQ#3^33L)aW68-nJ z-UTJIV<$px0%IYN{B=m4YlH{Y3JTR^&=a91T@O#m^;FkbLd_0D}HU^q7v_D{3+7bX6%S|LC5bzg~oOl$2|Mz_fC^c*Gzj|+^*qi5$2YtL*F zALeO8=Yknj$qv+S(j~qV9; zSL+D*svq}th7##VZti8g>bw;i75p&M`wEGP`81ucqS1^~c-1#_5T=iOEq(ny!1Xd3 zjy{5NMf%9Ldnq_2rlL=;`hKM33exb40Z*m$^;^xo2Ze-ehzkQ#Vz4Qsb^#5-t$jXKCH`Z^LWvf&Kfq_JyHb0Gi zp57+S*b#2CIAe&JSY1J~g$y8ME^9F``)#FZ?zt(fIE)Fk66%J=IVZ*j?|pgycwu?f zUh{sA$93bKe>g9Za}Uk}fs8ozQ$$@m5IQro>BOlE@R0A-_I8IJrohcM3Tb^Vq-BX$ zGcW>BY~lHRO*5ma%>b`m0q98P$3ZBH#GLx#6pjASJim!Z8 zt&2uQTlN$~$72R-o1)FNkemQ$yKW>iL!R_#5LiY>U=x;(kSdAba>F+ZsPWJ{Us?G7 zZZwDfd6_c~p8n{`&-+yc)yiqO{ApUDheIbXR~UT!rP)ghxBq1QHr+47h!{zLmLSM< zU831Z)e12~|NUx^hvt~UZOgLJ0Cj@^1a2#oJha7l1G;UhY&2%%c^m?1Es9J1Ax<_g z8C*7o+?z0hbk?TBtcgt#My9p6xt+*ZRXYh=D93U3SQcz8C81njd;0P|dg~a_F%?yj zUiy;hXEA~!-qHi<6%$ij%-(qZNsE8UiJ+AN?iy7-+wA#wPOpFat{#Q#P~*Ow^m*Mb zxH@D-dJDFrqj1O=MXp>eGdb!>sb_jM;YfJ>rNEmC;Rwd%q^R&)TDmxiD9h}a7vuXz z`JKVA&kSz;WE7V~Xg91Qn_F+v=>?(wUY}(}`q0dH*-N52sT(*{QVF&JI2g2>|2M}H zyj)bWKr#elanMq}VVziXT)cfuRe3B2c53eZ566bSGRrzv4=*hA-a}`PFFs!=Li`e! z&TiT+v*Dtol|l4mPp**UUs`ud31xJbsi&HQ%Zj9;bZ$Xeg@ZSV+b^F7?=H?xSwK=A zRLq;DXG!E}(0dh|-cW3>Qa69(BD)XzX=}D671?VI2ZTm4-Ty_N|8=eK#`7yMT z`N8mA1px0I&N=zbl!;`M5C|XMLmZ?8f;C072EXjvcHprDoxaSh3rVzYDn*hh=0jVD zigwX*!k%HsN$X-zh!#Szm7=@E1IbA&9UlE_;51G7pY0AGnzsMMO$F_t+h)3;f4%+A z)1T-MeF~DzLKi0H+M`TBW)G*X+q1}Zdvu@2jP+pQuNI{B&d3Dplov}9n2s{;in!sV zJ%%lS!BnH4LIH{tmF?z8fJCVw8)c60s)MSNdyeN?vh3d=a1a&5xJ4gfY^W#)cnTXHnx^GyI{9?kcC*WL9^iOEdFOvzawWfw-O#oQvQD+mppnH701j0SwoudPEM| zHm@|Y={i^GmUKT}nI>zb!^s7FzQOP$88S$WA+MXb)KV|kCjHc8PP$KB!&keM60f(uJsrjy)gp)_k2fa2Pwe+&k z<=I}%Vh%fTulwrIR=#1zQdoOID$Cqkb=TYo)EoB>gWbu(2-6Rm)FSb2u7GRY^g5YO zuGhLowSL$K*C+kRG={KE{lt>f7yBfJnw0|H*XmNz4y`GFuEL@usCcfT6e;oIC3vtN zE54aSKoCfIp?KyLuh;p@<17)PxYRHOm~afI)o9ais0s#J2+Hu@F?zRC3F;;&=tn&O zMh{PX^RQZi#_`H+0ux%)+!N6F->#GK?+`k={u^5}@;eHEZN%8Gu##6Vc1@XALRgbg zJdU*&mvjy$L5%m>hBU`hK0(V@8AL-3Wu?k)pRdXDUYcTIj-8HEPzK)~16F1=R$z{D zDL|3GQP8o%v?=b>@B)K55wr~12V>HWaQ@R>c-H=3wA$n4VxtLeDZuM*5`|h)u(CQB z?T^!SOxe0bX(UEjGM(;4kD6vkKA>0^ey~y4vlvfOfk@JeyS-s}ONZ3R(s@Qe_1b11 zbNr|(U!7{Nu1usc=J9;18^%QT?{*?McP$9gA0Seboe0gfhrk?FWKt5RGYU1H6ku;j zlq7KYD%24mmA*}2W>J+n(cNIDAwsm`{Bc9+JATcAaI4m@$`# z;x(isLeR|e-7F~(4S=L30J7UjNGxWCqs^9 zm}DyPVuw6e({Zvp>3bfTMQm0*v-*+49#R(3b5Y80vzlFw@CuchqAuICuA@?w>ufA~3=*}`?FB7ow5*gG}lR!K`(XAEAvzky=yK>Yc1s3w`NirgtFsEU@-h z{%wCf#PU3q&dn`RWMu@*c5{t~EM0SzyRBAwQo-}#@w>J87;g)$Qz1_ zbV(K_1w^K()9@hvyJ0zd|N7SZ;~+kp?(EwnQTK$5ut<(c%@}oFw7OlP z@!~kTdGEy3VYTGsYA~k5ENF}sKRSY$c6ZP$)K1l-1Lf==M!sFMWUkzmC66taH>M7j!zzF-mt&bZK)TO9W4XPEBgEcZv4K6g-iD$WiN2 z(16=IEVIN}<=wkDRRtwA95abk(f0m2bgKIJvPA!`6JO*Y-|ONXP6LG0pouQJX9X@T&-S#;s-5bWQC(s_#`qCua0n7_KAM z)XgC120=~yfAK8Wj`YqiAvE?yWs# zm_#iAQvtG3l~TMU2@@z`+mt~uGU2fr#xisMlb+-XuF9s)%dQq}8xRRm`NC@7vXRgz zWHmTTYqtoG3mycTg+k=(A+2ecUL!=tm|Z6eM6C+gjL>ZVoHkBbj=9jxIlimCPCFVTWniyblbG#7(%8~^Dtaax$RFNkRVI+(( zxD#k>XlzU|2-7!AUV`w`axUuN7KX2?8dRW(?)Vm9>l~ zQsniP6hX!efwZBDM)~#2Yn$0zuLn4{D?9n?Dv1>8A^EJ%=XL__}!glhz~=~R%)eR8<4eTppW>OcF*j|tbM--Yl-#TY)J72hI5(*_t7p+3+F2Q8;5 z#Bv-`zUjbM0vj7vN?3~M#1~ASq*)|rle~UaKv~w7dfi6+t=2+91$74n-7KRh>pN%2 zg1^U>q|Io)ByEE;{0EsxTU{eIpL803Rq+G~D_v*`b>r)2WBOmZ%>!?H~3K^0M zP!?lW3wW$;tp}m<9773p=%pB|euuAgzQIGbk^yrClzUiyESe&!od`nI%%W{8E&u(U zR!|dbgA*FPFZPcG+m7wT7hDyKAUw(usN)8LQ?|A!CwN(>nk=BU+Hwd9l0$Y$FP{W< zB@q_H+1{vsK`{pY|Ktw7ll}Y(p5`8duU#y-)NTB2f(~QwwAP6B_-Q@hHGLG1KfUWI z41yr{$~_M<#>T&CgT{xkRfx*cp2te3t2=a_=;erFx*=hty$*3(b#4JvtZ2k>UXJicbupheHS{-^&Lu~QW+z2?f@-}pn^ni7F? z_46dn()e+ym)uo4&NRgO85ZN(xYtZO%+=*D4$4amjDj3|?1PWX3H>3n)kI-MuBs32 zP3xMqc733#NRqb|rc;g557_i?&P%d^z}=g>$-L}CZPu2C5DVwW7BeC%l)lyT6*)A`PWsQ^>|Svhm>nFA)`dUvYB@)EjYwRfNtbKAs-c{wdG5M7i@Aq&aQFQB^J7OD zXK&xFf9Cc-UyJ`)t}g1U%0$!H_qmoA5yLv7jTLrqCGNd0AWgJyO99wiN|in|m2P>rvLRoZg3o9tKe4>rg#d`v5afXbLUS{EN0b!{oBfRAu7;dt28nA-! zpqD7S=*01mPDn}-T>CFR?c5O^e&tu zLt|9GIPuqy0g(cANcvLoQK-xsKD)VDu%OW_o4#?4 zp^*vsQaWX@HXBB%*rFL|4Pm%%s*x(1PH~|P4lIf;qKvy!^{4pRS^>ncEr=Z;Y9m|p zU;I%`)B{U<%8}43`=PrG{$+P6pK1kv59V`LjCVm@b`9v`Y?r|HZ|LTjsgN&CC^f zxv0ff-Sg@+pG&nwHvPOI7{f?BthlUua5P@q|!ssv1iTn6^UW z#@(GkWlYqxlyW8@@J9_3C&xmt8JNUui>fN*-WwGlW!0|vd7e?z|IYyeh%#*>{uRK% zo7Q%RbguM*tnH@V>!Cfz`P4)HHb$sG`+jMR06{ac>LZ7{FnwyLfwk#V2#1p6@Ze1a zl7fKARRBa$QOp&OCu+C!s>)4sexoPEtLrN}7dxar?YfeWn1f_8oeT>J>wAMzfI!n{ zwm4`ANxp47RBbh|*Sq7OWHHJtCc=e-fJ7_JY(;JwC>nbnH1$o`*cbF zny5_>FhRxD)fcXeUOV<#H)b&N|9<(wk8J@Mxq)`Ay1VmSq3ZYg9e7WrSrsb6szW{8 zrGdx_(zfjrKxoDY@QJZ(){0>&$2vN&zP69T!Rd>m4tS-qE(wT=2RkCI8mijkqgZ+_1I8E z&N-rW9$g;0Dmuzz=^F>rLw7acb1)2e4#XDME(d-qKtd@KB9nW%-J_E5HU_aJQMy`_I|v)h!RRNzWO93B^kNU5KO z1Pn<$zUPkDZd?mZ7_Hv&l-TDOOkTT_GCIgK7!bg$0IP|h*I@*5wZjCY*q^&q@y(P~ z`zHj7W-x{UfWcTpV=2G!>gy4AIK^_tqZNoWRe?kUm8n9^eL1>>Ko>gOveZ(9$89Q1 z^40LeDh&Tg|GhUJ{^<6)?0kch#fhHH$ofdP2XB1z-Oqm2qC!w>n`UA=Z@aaH5ui=X z@6d~mU^HtT?<&?_H#;5Wk@w2rg4mDj%(eoC3diC@PFdq zUeU9Z<|D<$SklyzWw?&(3v3RSNIYT@W?(wvh>1egrEpb^9jGA=O@9gxCS%aIt26wJ zg5(0*g3FHl;i_@-ReapmC{G6C5Kvs}tb~p8oAXE`I z;C>XN#l)lb>4t3RNf;}dD%PFiHnUWKxRFR|rboYf*nCvnJI9UPyU zQ`yGLu{lHa8h(jJ`Jn=|Ol^WuhjqcW!57Hz>LX#}VC_7!-W zYU4t^k?`ov!|wS)COd$%-}XqHrN$lZ%SHr^6-xO;JV&CG9s!fG9>K%`&a+n7b zCoz+`at+gzY3~KcNikxMT>kR5m+hG2+&}m>O0WGQ zX^J8jc;>&w79=9=KfV|R=0g)r#owi0-faY(TrckMpOVXn`ZOT%% z8jtvwfRyukC<1{FZ-&|cj-A20p)6Xt+UtuksOmKjN!|z43H*3>+8cVHYUA1)jv^CF zp*;F^0e)9l|`-ZCf%B@B=S zHh!%vshXo%Z>nxtobX7VYUCGnqcsidi7;({Y&2#@x?al^j8c4>s1hd3vBT!>b3EYZ zIA)iNEufdAh%D#C&mg3yI2oR?W!i2>%XEnnvH{qdlob;e5hY>B4(z)8B3@XJKQagK z6s@~PUmVu`ueM#8C@XJ}OsGV$79H)>laCd@2?*6RY;}zR8FE+)1Y}D4w5-Y?9jBO< z%x3bk0*5n6)&>{%9P+hmwC0vmAAYmMqJNP%=Iqoekfld< zmumJv@f66Igs4;1C4k@?VlAYEp7b7NdZ$%bwdoZ!SyhP&#+&!fI-#!W(C4!+ic8Qw89A?FweySF*7)O6|G_xtwaP|eJn zm?LTrkJV`)+ZUlvA}e%fxGoMwgG`kR`KXIC-KdBG0P2 zg;4$Bm8VjUh8}nSpW0&=!T|HCB%)RPkTo4o;N)#2u16K&F5kg%kHgA*C^HGTt^%Bt z_QAQ%QC0ppJl^xZYN}vvhyU{IIQ%U2n+RgQNuaL|-+TPVSHmBCyypMyO<-p4-3xp# zrx+Kj!_{uNyE`7r?yWdfxq2w3wSD${H$KKA481C4M3mBsbf?a1E+}7i+rHnX7&p@> z(+%GN&j)h|7l{0AA?KtwMMuUGm0Lfw>iYZq#Fab5bso>|H6e*UA0E$y8{zZIS2Xua z9S9CrFiZ*U<*{58M}=TkoMRjC{fn_JcZS}I<~l9`kz5Rc!G2n%e#^8iL7C&OO}w39Ce1jC!u zy#LJ9(Q+64-Z67Lvt|)C2+U=XsZd|~WWdS=@Y^to+Ex#PDgl?LVE@fl* zcaoxYqOQ~FU4EnQofh{J%74yn-+zODcI&ls`Jw#O%Ioaxjx+Ip> z2p%+p<&CO?wU(n{V_z~B@nr?W1J2h+PYuXsc zH|v14qdWxth!=3R=O6v@(Cu&t#vvSsS^Chjv6EX1Xge)D7VSD7r9x{K)64HPM$zi< z)~JgxExN#N{9&aPS|I8M{@%x!VliQR0I3lP%8T6$_%@0FL%be^hs}z#r4~-j7V^t> z?yJGUfK1q|rww(bbA+c1tYxyLYP17UkwrM^HBWz#^u!cDagH3{I{)h0W5{;o03RL& z3%uP`1YCc`r{wX+AS*c6Y#DnA9&n7un9I)}NHvryP!a1k0`iKEL5Mdr(k2?1bsn1c ziB3fSqlt1RboMO9)5SG43YsV_gyBAPdK}6a`(Z766`x zW4$|BkOVlB9KMsNEpE|+jc6xv;Q@_X;Js@bALSxi=?|kur5On!(~k?QTvu8Qu=N>b zi}D#&G*v0oIQit?!*f}#|2e%Gn(WHE5F2Ftx}U!E^{&;Y_%jKz|NZ}Mccy?Jvh#L8 zVl{It8}@po#?MJMm*eyL|2Lsl$?e#m`g{!B+@0}ze1+HjHI#l&D~>*yFLrnHgNk&4-|XhDvMm116-1c4Z@kz&YtjNn)a+Jkm2v!!B$D4!PEk1BV=ZEX&%;`2zpd* zOwfNi1>Z>sJ!3DB$X-T-Y9XWy5gbpIkrBefk;l;5B&-z;<;DquD4i(P%maArK&=#` z2&m%De~byxM=AWvT+5&*O|H020qau>$%E&5n@=yEw!?#io-sMoEqe?=jkkH>jOUTX z)3v88=SSWx4qR81RuGC1ts{!Zez;`37iC3s`>D1|9}>6SoXgCcvS`|z`8GG-HoppL zPR5d5X1->77q{O2n6%;SFKOSwC$;|X-y$ue&KdZrtvGPL=avJ{oIPY@3=i={9yVK? za22jt5*%Li8~1(iq&j(a?(LtdNELdY{nIaAx_bH>-op-YWuvPN`KVfrb}!UY2xidP z)hINyykP}vTTN)jt1?!1fn9uXt0)_>c{1ioQMrR!Vbj6vKKZZ^GSFvogsJSs>Y!T4 z!S+xFS>NQHv^uoO%8(3Db4ai3qrKZ%u7P@s1J}x|Pnig*$zt&GA+AqbuR-9bjyUwU z_Df0}6~a(yM$`S04NKZVzqDCZrz-7%pO>0N(07{mrFzDHMANfPxG@w- zk#&9Y`CFR{h4CqjlhT&AWmGDOz`mJcXbjYtdQj<9Hg;!(in|<3iiueGsQpy}k908i z3FwEZ1VwGdeia(ynMfEy7E4H+uA&%G!V?eJknQ^P|MV5Xg0^Ge7KVoQ3)4bXl27Z z76=GbRU%H_`L1geBhqkWL(~QU$fHQq1x`w^CWrIByc&<)l$YLOUY(Oh zvEJ(7Ui|JPXjWsfj7Xt()i_&X>HA@1jTDUwVJS6N3(#ql)Ue1Z&i$b2CY@Y1Sph-L z6#2aOKQx)-xOb(D=XAulxJUlY_+nOAm*+z`j_Vsbo5y*U_VOw)r9Ah$Fg?2H(lpDH zrQ61C)$#HwO5=1JhpyyRy@a$z90eE)t3k$~h6oQAP(&C;6_S{QB$;9}nH^U&FsAcJ z=J4Q`-lh!3MFb=@H2Gxj=Oc;TOF}QMzkD8gRht7Xu|6PV3maw?+;C2W>S^4-5e!|I>k~`Pu{`+glxv2;M+@n=-EK#l zS?j4V>p>0L zba?8A7IO0it}vsfMu|x2FcgSDZJf3YR0Ja;BqG^ltUof_4XfK{6P618mU~s7+snh@ z@OUw@Xj80mFf_){!HD3gtK2ly4RLFxDy~9Ede@Lew+ZTIK4O2dU#kwf9S~%#ngyFn ztgMv1O=ORVt(Y}%PPN9o@ey6g^epxky3&~bYSAQCbg0Xc5aytk{%y&F5ShWOtCvo+ zW{Vsw`*LI3Hp_Sf)uK_?4ND_c0ShcPKhty$FwiRH@XWb*rEZnF9us09h5a}kj^%h5 z*xq2tnbei!yHpG6yG2A@8O%64V{IU6Lh_VC2-L1M-ekHs|NQ2eg;C}_aNBhV_le^) zYy-*_?t^6=Qc_4ZTWNrURe8Z)US~GRo|J)f+vzyAk5D77DOIA6rAhP2+yC9&tpXiv zQu!Y-Ayb%a04f*vaio;N;OvZJcgis#tJ_Y+jt4#hx>L=7sciv^Wqv9}*0V5i;=|Z? zXR`4NRC}aY+MI&gwfD0`b_H48=2SxfwmeNCQ>8jZrE8*8P8Dxu3uZ2*^5b1@}2`VXu!dwXCfc?xYi?nZm z`~5tS^Y1&R!7qbOyT&oN^jAnIn|v~6Z@<~}plv)6NsA9|WzCUnwme+f?5(MVk_F=s zGwVCtj>SX|)vc`=T0+=(yPjiQxH!%dH~~vKs6^MwrsJHkl7XpMEAc^#!th2q3|(R@ z_2u$xGY{?d2fJil==w6ZSzDAV(6Y%xt)b7a$6%l^NEU5FQ>1{FU-2Q)e>Vzvl##&! z-nL>0;}|v~ici<&d0_R+&ee~uNz8hSuw|kGNT8N!2Pz1r%(U4t-Kc%vIRMRMSlsZ} z151@`Ug|o7q-|yyiuc~vPwwW04W7K650X41`or_Vc;AQ@wTe&`il5aaQL?(+LyMtV zY`)(3aY=c2S8yk_Q_!ZyK$qt|Shq>A1R1*kgi2=rNR_yg%aj3S0mwY#^Il#3j4s3WVtS6+o^@Jb6fAT*P#xt z$De$n6qqM|)VmK0;BFe`4uaB=QAZ^L$Z9OP4Y8U?k|K~qCB&#mDYw02bplGrNGRZ% zbh+d#1`uwwVqrE% zu3`Nq9ZFGK0XGb>kLEB}Ay)TH3N#f&;D-(>S<==rWYmZ;L%?5iyVGbB@q~Um**)Vb zz3DCPz%YrrJYECs^-t%Mq5q?mYs<~K z=cKFB%;0K32q6T0MYfch+sVL=avI~po>~JzwuEU~p0a{CCowYbJI^ywV}ma4wOdbe zxqX6(wVt>ij~(yFyDGWRriIFpDt@ZES!nS4eCap;d$k#msM2Ce)&2iY4RNxU!aFR3$}q zf0YmeNBT##N~?P8$TJZ!HtWpmsQbMa3Kj6iG$Df=p?-}Qg)W!Lmo29dj&SIbBfK8& z!+zi6iza15a}HrftP6pxbXc^7Onhvu#1SA18zYEgTrqXSNqB8(eB14&mZ4oft%D&D z;yL!B+In+iLm`0lWj|(+5eaL@6sN%NQg&VIC#h?x93i-AT2-4lXQ~?o3CO_Su-7b9 zpDcM-V!<%SfHf_bt_TPM!+Td*ns$L8|v&6V}ZQP-53vVt7=|9uh2JZ1Tm z7QbHnTDua~a)IqdYU&4`kQjv6cHV2M@H{JCUxRW7fN zI-YBC?k{J8Q2w_>smD(aCG8z6zyK!@lIQV0CN^w3#a5_@`%$==N77)9i4~Mow2Uqr zPVVQa{9 z)sU_;sMq8%NZ#`s-h66}TvDsmZuWyfTvC;nnWAncuY{R_nXXi5t|Iv4rf;+4vDiJS z;ZnA6`P@P#ntI=#GD^X6PYwx=UNT&0ADBM$T+Ce3dt`Rg(XP}ggwyOf)j@H_QC(57R(vzM4 zXWrXb7$j?*bBrXeZrZ)PikR1UHAF&4M3^l~ff|CCV!JtyHxLKqcI3&7NY=)|RPJnY z8fP{Uy`Z6x=$6f)QAMH83fl{xX?Eo+cV@#fWfX?S*&9&s4CP{G1;RO0gg{E7c2c@* z3~HI&J9ESN%{Dn}+*BsUAjBjj1BgKJh=D-9-fmht6%~sWw0?qMWX8tXk6gzaPn*gm zYxV)dWeU?Eb2t31P>&q@qLkYe211Fy;c~re&emPGd~&#!3GbNio&Y*7(>ODz>kL^q zi3y{>|5UtR-z#s19GZ#dTe+OM)DYsd)HF$P{;k2C7Az*Q%X?|hVvG0eZ$BM7ebRB( zS(AQb2$L`r_;Gs2C*E_6`KRCc@3wR!BqvKr&o^KYxe`dG#&ECkX%-O+Alw$9A0aS^ zrlYqGfJ?8bL=j`fjVw3OyhrD@^Tr>%nrYctQLQy8+VsX`iX*?}V^^`-YgVOOasU`S zq%Pox3v+jB|7pguZlcOo0kkHygJP*eY8WXtY$bCSCvpi8Bh_IfEJ}%wxBkvlR6J6E z>b&xAMm!PkV~=3MwKvvBTBL(-Uu&Vnpbhkhzb&pPi-W_a@ZE-&B zxZjTOo~pQ>W$A@8ySfe`Q%)XF3VV5QSXPVxS3s!0DjqqgzErAt?XnRY9&^&ZBWo%w zAGS_~9h(*jd^1F@vAWG>rXUY^aq#wNZ9Ux5SNe6HP>|p)QN6% z(=))9QzPMcQrmTGJ-i$*WeO9VBII$TPiKzA%;8{kpavSeL*byfi)}r(+352Ivjd@! zYat2qnYa>57+y#-dh~lTa-IlUKLSCBXH~RYM>m}G-Q&hWkI7U4lvHWKNola^v@RLn zn$)HbQn5Sal^F)Xuc={B&}BDJr8)HBgJHcuw+j^;Ui|)1h_9MT<`5rQ5xrjc!&Wy# z8!Htlr9xjd>voG2P*uWGwg0E^5r&6$oApd?zXn1IMu>j)K->GWE*29Yel_I0za(olv! zzyFaze0wumGwp)bSLjXKiiTn{dwz{K-tpuzp0DHo`r&_-VSDUhig?l3@J+wNc>Y&E z{I6f%w8(&GDWrk$8k9P0D44@Ha%tMm) zqTjBUQ(q2ND<@=Zg{ZRyRK_r>oBG@QY#pUv_}F#}?^ur*#*~D5V~pyxerG+?@xFhv z^0UjPG6EzGSZcJ08WPc9jpOY)3^L~Vo-TD}D53$3EvFAnIK$}d?2zcRc|q69S94Q@ z5ZOP)nhq)aGrb0c|5bFo%qE`R$B)_h5Jix%Z9<<;5yR}B6aNQUd6f>Gs6=7j9UnBl z63j|r4ljo3EKQ%R@K1vy3>5a4JYDGtwa3|3X?U}J2y2z}UPC%r41e&*8<6rFip8!J zuPAMW#KBPiV@To)7uj3J!0q#|L8Hg zf!1Fl%d>~GbSCrz=Vh@a>&$_{gz+4CVEo1B1=x>H;} zDIWVlz3)4>Lycxs4?jhLlftf!pyj6ACXLf!>-Q2S^wh1LJ_88_oIbJ@wY))L7{(qG0I(%MSl)GutBEk1`Y zgfSOpqOr{rD(+I&7$_jL^kmzBM$OX$VAKz{>f1vaYFavidexoZ9W?Nna+aFtF1^TH zKx~;x;RItU=nR%lFg80&lIL!Zp%9SCPQN%vFV)`CjD-DloU#`|xs~@r^VR3daHPfc z*@IkFR|186A^0a_e% zJz07jKw4l^@#3HpsxT=ymk>W1iFBF<(){?$`=*!!?jqdIi%;1$V5FD1kDs;V2$jQd z?cAg8P)Ck2)(X;V-E32xd1F2fxJK4pxRYSr_`8d}gl|_vidmFExln0?;|`J_+MBHM z8H`|~dV3Pf(kx%Kc#AVhvP0ek3gAX6T53bq!J?5FF2W2m*jtQc1Z>ouFrZ#Jd+7DA z!Eq-J$YqM4UdA;zoWaL-&N6J3M4v$o5<^lPf+5C?s1`<4Kpw>_Dw|p}OhOm+?Fv?b zc&3Z?^>Cc1G<)+>MB1j`mJ*MF(>Wd=)aXfQgS;8@&R$ z^X^Vkc6M#AjrblO9(h~+eDRKYJO!nPrah60U`7}yb-8xXKXB`WS0{CAQh9?WXWiQg z&#YE?ybF+6r$Fz*=(tvY-MmaazUe-H=ZD_7_1T@TnvuNtOc|3^z}4O9WwU~HVnWo> z5lh+7;HApxcFYrm;)n%3i$PKIOJzNnsNXj z@=SrB=U6Q%v-(-P$w>F%#L`|8WDod5|E|8=ZU77?)_-!hZOayixx&fghY<57Y>`p7 zcor!-VJ4qQpghOYdGfGA30T%mpR~T^&b(l8ThV={H@e&7)flC~yA6@bJ`G+TqVgWX|m+ljI<@Ed1iAIM>B(&Fr`H5W@!%}o?i4Wrr&YlD$b0i1$y<* zx3^XGY*2OcgT4Q#-+lAe?Z+|n+bwiLLI`4M8s`i+a&BtLX`bQ~xaOu5w{*1{Wo_y9 zQB-QdaWtTZJ`Y3JPbitXM6qU&B1mz(#;_!d7=!BUgE54=Z7Eezh!#fTzDCFhN;Mf* zb>Pc7qH9?EB;Jcz?W8n;Z?_qvw0-lz7~6D<%3T)J5KWA2c-D&qlNL_4DmTtvUNYTR zT#mkdeTRz?vgg<9{378-f%&bJycx?j&M?9`{a_4lW0qR<#WZrY)HLqpCFvt5f8ts< zA2s0Jyg4kSdiV&xH76GpH^d9h>vMieY^OCp9ZFzOzaBbEeHOBSL=&`tE9D1cu;~|V z2s$d=fyED~2`J=?%8NSsjZNBC=q_g9Y?X)@UQl+gF{?m2?>-o#t}eKDj@%0sC)zx> z6Y`7KkZd(-2g#{<^67%O`Bq{nCo3 zTb?@~42O4~YTnQw+hn_7R4HBx7nw+AoMq1a){scDp9pRUhRJHZNg*3)!P1#HJQreF zv>gyus7oFe!5C-sO}jw#KwbF&+nEd>C&#jwiqj$nyJjNvLk5S7ogcKjkozvsS`8JO z)A=i16MPI}Gk^TtjcPj&+wnRBh_kG~;hQBI`uf#q&S|pb9PWcxz0_hcjc7!?Qd{=j zV&UrwlvjSszWdXN$|coS(?%KplE`|8d9W&}wbp1l`t@uYDMw;@GcV~yI-3sp9kw^X z6mcsLZbjE<*@q7MK!R4niBTzc7|MV4hM#+m*s=NCMt=y-aE-L$Kn$=Hw<-#n&PO0YyiP$4l*{* zmTC-oy-i_WEdK>~_R2Ngx6GDjrqd;o-+N+jOAbBs17wgZb9=oJ(ek7SW>=QhSP0(V zG!u6mV#EwB^FT4S0W4r6sPNRGx*`T$D7D(?yL-~$L-;1Aim$8K&ewv1iMDzX+vcX7 z+5Mo}`YYPnw&*WDwb2`{3OPn9kLoa?R=tzuzrt#y`8kg$Fi$H8yjCKx4 zRgzP`x1D7)r#Y2WOiEHkO%;@ll`O@Rb~8GA>=IyEhW<@W=pyL+-vfkF$vzD2hxd8E zVsOxMo|>{PLb>rvdEHJqc|V|d^zxQm+w|HW$zPZ>X|J9_C6Vv&IxT`hN35w^S16>s zH%!{)wy}MRjyu~QkE1-t6i8x3t`IU<({7a7&+l3LM&Lg;|LhhdN+}djg#;QXJ8sbS zHzuKkEe&Vohzw6q-l9t9LUTg-xekIwoXdToJgW{^;}tAy$bjJVYfjtFiZlC2=j|>I zSfpj360Y@Q?m(=R_It>fZ$Rje%ocHiXs>N6Jtlzqre#iMMSN(FH>o1?{ns#IuA*z`FCS?4DagtXfb51cN zl+ygAzy=eHLiB=^UTkwpV9UK}a-wW#jNx^i#)9LB!&Hc%#KmQDN*r8i>IL=yh@tJu!?TAIY<0q$gf_FNpG=QpVbKQh4Tu@Y9jtvs; z^w5zedK)xt>RbYQ#@wg^KO%p}1!p%iq!aHRgDwD}2T-yPrh}HH^da2U@ja|sD|WNq zHQvtf6}P5Ewr6(T!prOH;*6r`a-&$O81@R1RR$Xbpn_}+8;06i#FdLCHeQ7W%63Y) z<+d{AlO!acH*CkT;)_&&Q$;^|>_4DdCX-UC?SL=dK0?U4Q(Eq;K)&5-tZGk?)>bQg za?q$j%2|{0yu2Kk4XB3^uO2f{%(kBM>2jv$)T(E_4DvrVH#j#ylXl7LC)lx}f{HrX zvhbVDEcyu@fg2qj-Z{LtUn6zMX=0spqCJ6B+Ig$K+|df!r~PWEmoO{t#ElfGg&XBZRtHe@I$ z9w``2jsVL1S^)~jr5D;w#Y})ZAzKW6d$;jBd-1{huKNqKX0ebMi;vPx zp)^G7#Xgv#Xpv|%SBl%%77j{eplw)vj*_N%ylX$xj#G%OU_=Yl$< z_NbkaQ=K&-{@K5a!HFG3r~aJN{Pl=Zsj_D-W%}|h)$$g3D9b$PLkNi>nJI*dXmf?v z8V9EVSP^i3bDn3zS(=N(d#9gVFU>11J>U9`BjE7ll=uLbB+EPjBY2cy#9TI$&a!6M z3Azk?rjniKLn_mYPElw&+No{Q4o~*v3v)_8XnV>~NjfvS4D1Iznkez>s&gOKy4>OF zo!JtmIm7lV*q8ml3uA~Y=avCszGKR~r%41; zGVi*s$Ji*2saUFRRIQ;{R57tRjJ6!632XCp-DsqWt|?5>!U5H8%mcz&FMVgb=WY?8 zfBNL{qwRzK7`6M+_Gr82=I&;q4fgtO&PBCmEFfE#SvVzb>wt~s9*gqaArwYC@&L$d z>mS958=L9!Yj1jQqUp5By8;?_At*7n(l#o{7-?1^c#QdsnwMuCBCafmBxIO?5>hmX z(Ji>E-Eq365gnX)P9ml>Pz_xQ>6>PZm%|~wy&rU(!geUgMj@wG$f*woMAOlC9A53B zhk=8I^(;{GnBu|*{pG%)6aiJXl08yMmg>>Y&xa}m>&9nsc$eE_%;(IxJd^C6_`;V2c7u35Y`)q%0aghHnr3YTuMd6a}DrA&$nql!qtr*CqrBUJA4@n@^EU z*XPa{_~JEw`K+CuGb$36rFBAl=VUQXdBz{@Vqi526sl)2LeD8O4Rv)_o&&DMw2NgN z6eCL5oyGAEmMNA!Dpq@2o*Cq55Lf4zlbH1(rdX2r%f}wco~+V|bxdp$&tkJ9H^$U8 zyxhxk*#O)|&?gFrZ7h1wjdA+~_b%^>yO1G9V1Dn|?joK`48jKC7KRErv8qyHUlE^O z^@NwM2u+5f^yKlbg2%SFakj8{yjX~4!ye;9AD>3Llmg2}9$;|t#nqXO+Uz@~nbZpG z<++p0^y>42yEcok2;wkv$S_^w6j?YsX98?}j43Vhb@A@-wQ-4Z<)vw~^g4 zV}evj!FiJU8@~{iE%`B?;Oky*foisf04KPZLvOm&{7wV$G)uKHYcm;*md_hn1`ddx z7X~m~B9D`YU8#UZ-If{Q2M5)l4ki!eszMfVyQp{w7SRCR1?yNjj#i+??-^1m#>Gayv4iHdOecO%bcL z%;~n8n9lhRh&bX?FPeGq9BI;o5FDKNadSG8vJ?C?-xtmRH%*n54AX@Au6JZK5lh=@WAEzl zUJ=FK&CS|hk+`BR8Kfd|2csDZ8caiwbvZW!s{v{pq;{<$w*n$0wJwA$s+;y${8i(M z?ET^$Gp*`wg~S=fUSI0A)%qT6`b_0=>h>i!IyGkoTh__ z*N1tjR(ildNkv3O*j%*a>{s_(_S?>#zp%Tr7}KM@dK5=hKv^6PtJ$eVYSVVyFsT^NR|ZLyvNEM% zn`Gs(G^Ofw7@9xMgn;?8*I|%S)*7mdlyB;N*bd397eki9=9ml$MYj&h_vmN zt&dcfa*ZGjJxn~YB!7VU^WWbqeo$H?)=2Me`{3&DBw4@V9$fm+NpF$POrtIZsPZn2=J)^&10E)I_d643z8K` znQ)^J5egwMHjSe-MiX-yA=Q$69GUa&;kHQO&wa&zbJ-}Ujb|kF3-Ed{m>2yvxp=NC zR9(D-TFy*eyUrSFXzlKbAKqEQpNXq_w`{d`6n!_KzJ8ZmtMAU4eK*A8qaN?wNjSu> z>Q(kaz#Me`&bvLzVk^dKIO+#mm~je@oVoknOW9P#KRo@oh};v|)1f zP7w7|++?_s{4JjH;EY*V!*3M*4KUA#>Gk8?;fVKsN4y_$#Cw|~-k&&<&sjv!WR2!O zc#pW+q1xvW|5qIG|Ggvr&pP6Nz|kmlEUx&!02$BZD+g$gbqnX4RMkGLjUmOk zG^6*Y6l^SpP*su5_rZ4|vOkl3>-M3#p2^I3*6mJcYb~sz(AVv}tjc zM&A4@Q`>wZ6c+$FRJ+erlh@w6UFIk^;T9c5)J+-+*ws!5i-N*FOZs0%7^%Hw%&aI}El?9v^WCl`3djQtd(Zq#HK5%|*@ zq3wOW(QRu(_qxclaVD(6=Z8(1*_`zBCbosDifM|^Re!Et%Dr;9R4^Z`kOE?t+NP{k zqLQNq4MEWemZM5!x%Hpi)ad_jpvcEh{WVG+Ff-d~s+lgVqY9LzCm!lHO->>XZud&& zcPeL&YkFqBP=5QoW!gnFgHKv4|6934x4N-;J?iot&ojvLTv+z^9i9 zU5~k(5jj~4(sN*Ek=6`@*&=<4LN)q<^^f}_B?M6>sV5=eT*0}fmI}=6U}7?51c9KW zONtE~G2ldj^vO$!C?%^S0rY{E2%RR5j+YK0P6y``^kLXD3AJ!PmpwL7s`*5d z8D>dO`2o`7{$WS(akP*^aD_omZvuX=;^gNbs9`-mr@9R;O_gtA9!p*HBw&^Oh@+akqgS`pAXV@$Mng((hDmR;oZ)b^BS4 zyY$RF;{)JW)#`xYVYa)#T{-vcuV5IeMFk=#_Syy(;;RKcZ5x;`ywj-)_N#vHKR1Wt z&_2jD({dc7)pGq<*TB)Z-7FAq2esQ6D0QnS#`7Pwn=~kHCsdfQrPu`|84MK=jO$9Y zlC%VQT0DxPV=S|3fs>owGw)EAED9J=>Lk+k z316s@H)bxXH;^?>h%w#%3Zn{$JVUllzGo@E(E z68Ft+Bf)MS@yz%Xa!`x)Z4WEUk$Vu|=M_JeLWGpSp?#z)W{nuPj_Gt#yD4y`Mg-sH zM(z!&-s)SmIB#L@tVq0N9o6HtuXIlK+eF`fd`9w%ccrDxG5w;!cruKcZ*IRZ0}M}B zO^~a-Z-w@#ubix04%42+5IW^V71A=*pb8^v!!hhmc~+1%x_(IND7MwwDTOd6O4LlT z0yK@W5|q8Y77R9R^L&iVyKO(Jkq{N5heL-2bYF3@gNh~GUjQ-_u8*Mgsxvlv;vlGF( z8|KKbQ&7&?S?sKKGP(hYWF5~eO#`y-#R-qhOM_9r=Gy(RV|j*eYh>^AkmPmCmS;p$ z6)=)cTu%Oc$)=c%Q*F!Zdbxxp7cN)U>xip@!Uhg=HSc4GbIm}Vj2hD_657p0ttJ;U zN~a4MFwzjl2o@-!f)XPC#100kkQR_ z=ZBze;kp_Y9o09hL;}h@&x&ZOvcWvcI~X>FXQ~GZ9uxiStE6LJ*o)MlqIwFDnw3H* z!xWE1_)Cn05EVB~Te>b(v*7TX;#obBu35b9Z&BO%@K|sW$EC;~JlnrCa2*Qm*4U&| z6Vb>bSZbW4!hl(iQp^_IF!7|pz`G{Kn1D(>zYUOM-Cr~^(}mjdRdPSt`8^c;+O=oO z;W(Z$3QdSSvoQNN;2nA-)&r=w19)X~Js2NBmklR;JrO87RXX)|u`9w)U8ga*bo#=C zGjE~ZTB1v7 zl>Wkk&B`Fn!YNU|f*G&GoQ03#@C=jUt8-LfE2Ml(N}P1(Q7bsoLsi+jMYejMYNUB` zAqhek@MmS1&B?H1neh~MDZR$T{v%&G&mPH#8kTG8FV+(9pw1@I)1KV=aj5)k5R`(2 z2YET!`DkiY+&DgW!d}Ab$1-F2nujrQoHW%j3`v$HNugF$dcn#N zdU_vZbSyADa@6Yx=D9E_!J;7|s>U(-!kpYJ=FBm`D4)m+8QBAiNn8c!eOF^!Be8CL zim!`T7qNPHDNmo~w$kD@{i3R$rKuG9&2Ru9f+UaT=|2pHisDf;tEcH$W@Og0REvuJ zA2>XzbrS{?TY`%AO=rDzM~*)iLuA@ch6;yTj5QT2dexzCx0p3k%9D8o$qfq*^ld$n zF}_QbT0MJ%`;!}be;VdbeXD%iA!Ac{+HSxBtG$C?nA$-rb@iS&h;wZ9;q-Q)94y}u z^{{rVNEF*IG8}^*JDyy$Ry8lND9B*YM(Qs_A0X#(+TqC5 z?#$W?+KGQL;x*IWR*{P{Z$naG3mt*1=fgsS9Pi7mpRm&y>sX)(7$|jCF~}O(EjmIb zULIe7?lhfp2S~OHbFWQ!fbr~jswX%$PACINm`Xh3CRe61te=7 z_Ll-2thg=LP*T>QT1=Vdm;pb{-Eh8mbHA!MtYK5xe^XZgVa}|V_BQPdmecc~h`i`X ze6+&o!?V^dh?@R`OcciIjc`u6J(NR=yVJ3l-Q8fIFA=B3^Tk5(6mHv3+8*5_#RE#t zd5!;khC?bnqZuA*NY&dFsir8%L0P0|-YfVHRoAojg+el`xId|QrD-{DXo*;W7Sr|c z@z3rLs+}$Rp%YC-OG-ApBU3lpatEWVRs;5fjAWA+zMruj6pWojY(ix!h4) zl$(3~W5z(}yAJ~zc@^xj&2eWzS+Gt*&WPnG)t$htpnoV2qU)OjdKgQO(oc3uE!b<^ z+>>#a#z9%_N)-rrw)IBFE;nC{vXWqAywxMEBT7&Z8_*Dp02GcuBSqX8(wF~04s+F* zHScVUjxVr8$iW&hTj@n1qaTKFx@vw z-^NxX`1qNo;?Wugqmvf6_d~Eoxola~6c}9z@e#0GQscamHP5q5H=d=dLo~vs{S6P2 zeDuCYnNPq8yH<*u!O9tlWqVgcxqbf?&~k12rlNobFbyDGN*7&3faM?ttfpYjuDX?h6YF%Ee4>B z3FCpyWGhe5g6u}1}ED-norf?tRA!y)HZ+BXU5@`B; zC>nHoLh4JFeNe%$Jd*wzloL{yLC!DJQH93J!R-vPRzUqrtr0P@H1&&nvX z%NEX8TkIh;9j=pcorp&OH+39FtEfc>`#?JsnBFn?*?vz{dfNZ$?;%jA+U6?>Q3&7p+WZ>eQ+exI>6zt_>S7DRpNH zxv3YonQCYBpsGSA1iwtuxNT2gnqHPLZY?8_c*Bh)9p{p_+(T62nh5uv+>|6G*a9UT zQ#MK7UsCqMzHy;4l{g>a=Y2HQgkhfvr}$WM`}%e0MwMR(W@(*8Kn+go4k;jZ(7wa zbdT(n3t}mo7%iv58_wx|CbbTfqKn{Nc#FeX6?ZLnt!Udz=Oyjm>%QtB8jgi%-t}qG zWFx_hXn5##EF4RfirI7~+0XZf!(PV6?^*mthGf{@-p`z6x@j)87J4Eh?ukp0rJGhR zFeMqHFiw`D>#a=6uku=d@LI8qA6qrP48uZ@<0M(yi?nD{+>C1Jy|GBTr2Y2o>W;Ty z;()4@?7WT-FGjiXuxpF;ZupnQX0v)2J5YQBNJ49;jj^qAln?k0bW*j46+5JL9Rvd9#3uExvCgX7O!Fjjp*~+6~g<$ z#C&CYs@UbHaUppF!*Oyolesa`} z1P2e=231I?HK>-OO|xz-I$Q7)Km7RSrSE83$7C%f*;cd4COhqC9b=2@I*+=Lc0)kC zPo|6;|C`T$v;4ohEcAjR!8wWXuWUZJ?2Ft_tl8nT5M-j{A_71oT4|g-|4Rf(=W{#@ z4L!qjYO{sidDXtqATUHVR2UfON}-|S-3`2j>N{;jdZCK!U5O;Dxzm?1KmD-^JAp%e zau5-7f@(9_?NiOM%oSrO{L9J=sH)H>qs&mn)?6mUXd07CAT$SNQ8|TImWv_qZItDl zhca)iHU9e~UP=50mg=!#@rWf!JO*GEPDKR&eE1;v&A$=$I~F{eGPYj(w@8RxXdJ&< zoocn|0`=yX%KqVJv@Fkz0kSLv&Q}_y;nj3H{EQVIB730rqwkPTsqvI4L-*gVUw`|< zTVH(s#TTEvfAi*`s;_GwHHL)kX;G}I;OnSHb#A+3E9}e3U^JWN++L`vMc3n27x#YD zIW2;!5r$V$i?+RK`vhTB5S2dH42p2gc3fK^f(#TDU>xS5t~}&j%RW6i28G6nXxFV> zy;kkoHELIJ+fNqHT>;OQ57Ve%p1S(B;7&Nxbj_X1U)^Oe!nzs={-JrUk833j%;?Du zx9S`3-}~zElq5tj?@5 z?5J9oCPjnCDNKRZi8j}mJnyIyNJ42r$~zOu5HTf_ELNvU5m#$4x8|4B)S*4l^1jvuFff-D4|7{vh&Od}Mu6y~qFCE; zWm2_SeKAa-_XjsK7BOYvPx!i$G|_Qyjd2-JtKk_A3+jyW##<=Qr$j2*0Kdv(;{UrI~SPVlgG{~XeBvpKKjZ_RA{j9P1U@dE}fGZ#B4 zCHMB_WGpMpKQN4pC&Mpw^lk<@w{KluwY5LdZ|vAtkr8^=4v{&fP#g+J6lb zvNV%!fQ2EYkv!L4VGIY1atbj())b)5xk4FeQ!I+PTqTpInGMs)QidA5lI)0-w4g3R z0PfOsh6}Trfn}i@`Cm#m>+WQ4LT^*&loUT%Rbg`wg5Nj^$AuZs$IZ=j!4xxF8^aLZ zp+5e{A`LC)Vi3&pqKkjEX2a4}J2DA@0PBf9^Jb^2rNS^$s_OK1Y`BT38`s1~QQyh{ zEWUT0h5LiY$0Tc}B!&_Of-Dl4om4ulIm4MGxpNRjtrw`n8=b&KuT=dR2!PUEeqvK#Z;VU5FYF@LEMVRTjQG{+!`S zVGruN6?~3kte8o@IVYkE0ZGp#nMo|HRj2CkmT>DjSh?x$%)|HId-(9(r~i|%d)DiD z*(M~*(rBMfVfTzOfvsv?KlxU#Hbpq=aoZ$^h#HQq>1X|+lQDFhNze6;2Lj~QiycbN zjk^j+OVM!%=jXLVm}C8JP`c8pOCIf?9fqf>`#NkT*8Y+&1rk;Tl3k-myYF2<4D{#j z4GYdym%fax$||)#yFYm?z;qmk*;}3K%+f(z7+3!!=N&3ymQKMa{>~)s`;v@ezToZ+ zm5IwTQ1xv{yuh5iBglR|1-;coBo`)l^ymJC+}yYKO^ov)8&7~n)7!Ii$(S(D*^&ys zr7*ATK^%JP^$YfxgR}?{7Op;Ga7I%;JX~U_%5`FrfFPMl9)OSi zofW6gc66SqS8Moo*iy5;HHZ8CpNn@x&?`EZ-Hnu}Hq-feSSNngrdAel8x?;}f1^rH z2_wqA*hw?SOEd7lF+iQ2n0)l^)L!lVG~Z_Dk5_j9{<#DQ=g%li7a)1NnZbFHPL}O@ zC7$G(Ja+)xb_p<uhz%U~eR&cx6gC2WeQ*3AAEW;K&M= z5IQ=>Ve@mKjSxZ^VS4`byaxVC7;5+F}L;O+pmg_@=ZRF9r?q{AmA4P{N&bX zI<9Av1QATRcKSzgXlK>_rR}9H#c<_3zMxcXk(!KuN*nIM>$x49A2Ks|BdLk!^4yJ0 z?bLgm&$KBnHvpC`m9_20Vpw9kK zyJCv2{?D_<@P&$^em|xFxi`Smx^vj@puE95c@v7)H^wHDO>vig6S6VWWOelM-tV81NZk5Xz3RCM(~z zEp}PR>FUVq-exx-2Wzdi)NmgBnoG`6?tnldvBQWmJfy1 zXJl;K=Kxunust%vIK(Xzw2U-Z>~qpei4@?ZL9S)CaToEaJHY2_tF~@GK98LGru|Ap z(qY>mu{z31%qcV*OJ_B7%Y&3L_4~zH?bRAN51E<29-z>z_$Qc8!yb}WR-=bUN|;S1 zR>@=>G=#8a0zR{F2;o#y!tP6^q@UD9t@E|WiVGJe(0dvTXRhc}nm`-PEY>x0ctWTe z?6gQ3)VMT4E(SyU_$|%ro$wOyPOvQhpJSWZ#)55)Yp`Sf_3-YIJ^QBWYF2*5%Du0&zN9O z3H85e1b02vKTaDhFMbLY;V6%S4L}2_cE4;YR3iKK%sB)3>|bASIA^dq?Umdz;#ipW z_S|f%Vimq6DVW_tjd#AShl)GXYO+Eks$VVpMIRjbPi{lSC*=NF(O-in^96s{ov6l( zX{6_UI02Pgne2GZw>9k+?~)b}5M{=$T?9+;Gt-fFL6$0Ll$0l~JLVfMCA|x_)GX zP{n|luMo@K=-w+`{P_K|{YUy#M;g_)xJ(rysGIV*tFFPredSyVMv|)@4ooW z@_!pK2)jr#q_lbO@E5tC-7vMs3^EEOH=dE4;-q&OX;n7}vT+s0FxsbR2CxgEE;p3> z*d77PG7;X~7dP7e8Zzpu&zE3G^DDqosuilN2R(t9{3^Nx-EvB@8iGuGbU|cen%u1$ ztJI-6T9$(cC@X6+TUC_l)&*#%goyehaVz?se_@o-7m5j)3y4n@6ot)G3dJkc7qt3 zXiwJSruz2R`1{I}ogcgeCXSM)P_;3KlgXBy7_Xwb|AAFr8Cjxp)b_1gDj^iSa2V;xR;nh6Ct@&9D_V!)Dl01%LZ`zw6e; z8-McGB6yM^?+d&Ul_2&OL&+1}JQq1qn-U4^ zz>Dxb_oQKDU7NAJn^eoCl!;5PUdxsJx{ln1sKYhg;%X9JN6nC^Fmz)HflLJ26P9@( zO*I(a>WG368OmI&s9H>w*LDMfCQ|qX`1$#S_yqWbUe24x|Ksg1Y5%ofUw*ZNMVC|l zd)=Nxa1t(=P|N?qY3XNgJ^uAlm)~?%^1SE8xgIT}R2|b9`S1VInOR@K0n}lo?RHwO ztUlMW{_A;+wW_8%98BtQpPNHe`*}IcoKce#iXnoN-%-s-K4mkwoq3RYKgg=MF^+`$ zCStEHAplEx*}nl&E?bw9B(w$a)Dck4sZr^cc8q%I5!AR5gMKd?+j9?6 zuj!(S_BIyoiOq3on1&!=7HyBVb9TT#?z$!?R2>tEAjk*=Qdv#f4r8vP8;1mwO^sRC zX0z&V-9UNTV=;EY42rk;-cnV^47wmIXvtbAQ%b6uka#7TqDP|MTS;v#9O!Cnlymnm zr*kktAy)*4SfLtOej$EC{6hQ!0utD?t?HBS*J$nZ!GHhpw|A_=Ty2q8%+6=rR;BtSTTdf6PoI0%OyGqE8ID18FO<^V5O zP->P`Ev*TKb;FrNJ!%O=?KKa`XegseBm>Ad7KjRkM)8LVAsTSDG-vvGK6Yl zV5Td=Nfft5Np2otED9;D;w@kV=evI%3=0{7)JBk8js)|WZ}XdHZqUzpV1?7?+y_sD zkW0*|Gl0s>oxc=giLbS~N?hg41Hcc{f`oPdSUve)6Ka_(mr&X+a`@gaJ+t@eH|AHt zR2DYBo$oHSV&ng5s=csD9fzaV=oV#X!ke4$KyDCWg!M`kK=~)@db~dIbEASMdPL1; zHJ$unusH4E!7L$0`^dJ1B5~*j|F)`KA55US>Lx;Yz~^F;UaMEFbf=8#-QWd`^Tdkv zZQfdLjm8I?mN`X?mlWZP@5&%IXXxV?!& zz%?!kMl@5sZtnT;)chm6hA});(+(Fr@>sci<75fh9yepK>DtCAE_)p`gX=9ee8_PM zpZ+J$uWpa>gTRk6{IOYB)#Bgf&dbK>s!zNvcOS4YY8`8U2*X3Yz0}*Z&OuNf6R|=ok5YTlH!<> zPJ2D|brND<n}CqV zGdhyHG|?7NNhh1%ulz&ki%c&7JwU?0_O#NA^Ok^`5uJxv+4Z}_w%Ss(G%u>@OhE(= za5#G4y>!(%$tXJp3)h_k@{Vq`xieny=hV9>Qf!1BN}$K3QWd$b-WtcY*rDi36(sJs zbX{&wNsc4QGEc{(S^BVI?OfOee|ax`B#5PG*zMp^HVM;qMaijXz&C~?teokGu@j~S zR@I?uHxUKqO?n>RT>Zy+MPKB6JThrBrJF<&E6Iy`UE^ng;VODI`$2pY6j{I03hzq& zp;RX#8$T-J5Fj#qoCotJwnK<7=(Tu=d0h$c9kye$!X&{tw-sWUHQ^$pEm^XL5R%#Q z<*UNuO$#}SDc9Wa0p7Y-v?4YsoYV}(tZb&4$SE%M3jlT_Mi;V=#jH_d z(n3c1!LcnV@YVP8IK$NinQXZ!vzc3ottIxi#jnTW8)GB!&e^>5;tbO^#n`={DG>3> zBOjpb>Z*Mc4*Cx^9sVXeMB2YGdO`Nf$A59H9&J9>R|ndGSzH6ZqIl*6Uqp zTR%GdgAe2^Q-Sd^1t8tLpE7_ivzR1zr}J#9$9DG^S$MInFn()bJ;(bqm#Ol%2dWqX zNAGn=FO9P*?`5~#i`T=pqcLrDWc_J>>Y~VgzPl6hJp0^5O}ya7Wu4bLXB$;dGwepU zD2o>WVn6w59JRZCbzV2)Q|nAfSYIUjZ3%z?kx>c=n(k@>5`r8eyy5xPEV>lPKUXvj zAMHEqb(AH%;4hfqF$p`jiPd0h9*Tj1`xR+lcwlJG@*=g(JeF(V2ap8sEY%d$jQlY$ z9AD1WRuXb1Nm-^W!JnJp*c1gx_^yuld$+7?)kGfP{i;{3`N^rAaKEA;etZ zoqOQEeCPlA#6-1iPhey@4+Q3x<=)qgSd6}|>ch*t?K01%w1}UF5hHZ?p$tx3;P+g2 zNk4^cLv`5yq|Uhos>LlOqW4nIWDsX`I^f4!4`a-_>wAKrmFbVyXJKF%BdoRJ&$;HCVMpw zJBctL3t1aHV5R7wPU>;NIb zMfofGQ)>Igt-?6d2m7s8kw4n6#8ln&u2)heB09G7i4~q5kr5-^MxoW$M(mJAfJ9*}?3+U_M+4 z(dw5c$Z1_3$yMQF82QB~sQ<>DPu{_HGV-JqwA2zRs*;z_gM#>ws94net1z=xCBTBvg*3?(cX%Cf!mDD~gkxAI=sIIvA% zCALRv1}$kxSdZ|Ay6dpy8$bga9lN4S_|mtByG1>L%~B^Tqz8go#Fa7g;!y*0?ssQmIpzJr41J=Pt*axd% zU5eNB^p~ZHFby%I5B_P*b3jr`IKW~`Q+IKgfW&irD z_^@!+Pa&x2IRVUy}&ki?^HTzHl$*`Zh~D#OG5i?5aX5>aO* zDT3n>WelB9$$?|S1_$b^bVwnfo=d%$2et1z_bO)luL(-5knbut7RvA?Yxw~ z{wr$U1P?(@$>6n;wx>-DccfQt;xQwSB*w{A&HVJ^EQP{FJO5X{3smc$IcK|hc4+3v z*g32)Wo+mJNX{**4jS0_=XjQdg0P0Ic|b7(Vtxj_< z?CjM*+a$$6y9*Z=S`3tHCf@Rk>Xeg~UU}i^Wulb4XC*Xux2wlyz!+y_Z|P>$dxo76 zSID5Fp5a;P@>y#0O$B-en#sg#fxSYitMaE(4D(&)-!3zjREW7GichJ*;MPf&m zmtC&%leMj4>ZSbjC3Q}fn1n=#nCKR<*tSj1GCrA#d79_2ux?$eEujoV_7X{{b`s7{ zINvE+v(K<@kRixa914eWL7-4}*c@ql`Zn+muDx=bd)|!gYiS?$nwT+Rl}k;mT3KQ% zk>=WxMXwg3hv5DykP*Mk*|N^ioTk7Wj9u`~?xmLRo=tdg%lNb7)p(}gl1(FtIdIqY z-B4=8O+^}W7{^k#e%Y$j;%AG3GUkP-`bM?FGIj8nySnS4ubcF-xhu#df&{08Bl9d+ z*a8U)(VSk33j^Mw=(I`2TBmiQ&Iwr}aPz_?w&j}QpX)V6oz=YW0W)fLIP0+5DqsQqSC4(M6A# zNl|RJj-_0>E$17N)T3R(y6A$~G_xzh4f>e|r2~O$LOu=xKp1JXIP+YfqJ;x*&;bz$ zL)XE<&Na89o(tNm`VL$CySUido`BV9tTS_J29suaTAUTOd8PoU)cHi}U-rHR>}knX z0}n{_#W}3s+;1*%>`@7@PM<*Sc3M81&fX{bO) z#g^sJKXyh6R~xx-JRJjqqe2HXNjfHXJ|k=mmmH`$8WWsCtdjr`YhTNpsAn}btE3>U z%3fNg_P%>dNqJDdfG^+|{rrO)1pyI&R*~2G=xckKpl+?wgDqp_Ep*T}QP#Qhg!5&i{yG>68m24lZnFYBXEXDTbe!+?#e3qX%` zRoSxxO1~Ud?Gem1G{n}x5aoUeP~kIWkU;_?uL{*68`3h+Gyu0?cPQpQayi)fWy0oC zYwAW&9%dL>>}Ww5n{YOG1j1-``wD;3XNNJr+Y|z)b=)A{BowUQMyXO4@jjBH;V9WU zUv%C%U)6t6YK@3ZzB?W% zP=|gqbqzgADdW@pn(XnN(uxdu-fZ5g%>0#3L}POcw#@yje+%)+RfqdA*R@qihGK8 zpCF7N`i=o<(i*BYK{SL9Iys?nSW_)2TiLP8H?++R)?~W;_ssC|hJ79Th3{qEL9oU^ zg(Pm^`BXTO^);DXra!^9d0CTUV_dG%*?h3s?7R?%0EzTsTb{z+k(^Q5g`_9{siNso z5qfK+7cq@@X*HR=t*|)3f`4Av+*+*tiF-b9q*in$3 z>2N*YC+C-x+{kv;7R$hr9(K!#N}^z;54+D93f1Sdz1S*Zaq2`-U+~As@vcBwaS(oq zZZXo)u)uu$tFoCWLEa9z%TGM%vm-bT^TNNS<`^2^aQe`RjGV%X(C8_+VJyQBl^AB2 zu&9@m#jFL&fa!XX4`Hiyy|#^vX%n%#zUjUh>T){2*1!7HR7RRQ-J(BH@i8o!D6n=> zoFN9bhuqJ87*B|xQ|Sx8NRSHHG4sgU`XCRRy6U@y@Odb#nK8>xCw!)E-KsK|yJeA2 zvq+`;3yF;_J76{<2}NVe>f=m;fIN6^8W>PxuD7C#n}`}6|GE)!=jdf8L}2t65%%IM z$`K`~HAD0&7>aSUVO@*X+J_A)4i$!UJCGoZp`#Uq^r)Jb{?ms+_HW#F80$&GfsWL) z4l`uB0&cGy&s|5M#?3mYm+=Zt5*VA{c)$eN?7t(@uF~kHQ;Y7-xB01p2V}oM_%>dq zpKSdzY>P)?;dlM;NZ^XJBe!Cj-F9>d1+G8K{ebIN!`+^L^F}(i85-iPnP*uu(4d6_ zk8Kz=h3-cP&A=H@=g8+npEBW|078K)Iz&W(tVirFQV|Jk)^r~TobbhJ^DoryuOP5z zgAC-L*ModMi46#@>7}Jt>G6=<`Tg>gNk*H->OJNhO8+3KyT(i zxWPtUn-L${(LZ5rVS?u4%ZhpLD;0>qDv&&-jQ&zqYpPZ#%>W1qn%hN>>Yx@%lOzFfw3OrM{tIo=L~;=~HCv4PW)4 zP5dtcGIS|_UAB4Kpa5Yr5Z<7~okN{fWNJV0I5YXqpc9@;EEolEL7Ql|Z&P=Nh%Ac0odAQ^81dHN!~P(WE(3sl9PvV<-X1vY zf|s+8okhfWJ7#a9;7TVe4YqA(sE(90~BqgI7p1SK7 zLRKVo_Q_0cC5S_;PoWY0v zA+8xa$5Ba&{`{E*2Tfu$ci4bNqQMC)MZc*Pw~LikS`F!bMA?G zJjPU5O?i23bAl$iOR{@NGWCBG9AHz?qf-cfajNi!z*%m3gaPOTGyTz#VtUb<=1Uu{ zrfT>>F(Ssd3D##K&eT#5HK)wP4R?uV_eF%o2u@FrfG8f_;i$Hhb)*|p$s`^xf{g9x z)z>1>NUg4rRg^+SaVW!o;70aq1FNfYSwTtd^n~X9{>L#AT6or#h~OP)f|$Tm804J6 zsFB+R%T4RSV|Xa4$i)*`93VyD8lL6)M-L6bec-}I>(UMKEY?=ifj3l ziiz6M`L!|}V__=CFwI!$8Ayd}~0fWfX=nGL**RefAWbS%eKschB*G9#M~HDQ}{n zQ{rpqkvqMUT*!qS$w%484M#;kmFk^}K=o#vuYoJiQX^d^g9lQ8aHKdbV|UQmq_$XG zJ%Wy8(m0X>@y?v!$`_f$SnqStF{UY80Ek`brjs|xwQU0rwOqaodDcbp4M0d$5X8_3 zYd65yHpikSZ=PC*0D}RhICwjrmwyg989Y#8rkFvWa-yi7ES#RCb>r+&D`C1bH7B>$ zji-2}h%#gLRDS)5GPyBmLh;0~tJ~vpsw_?}mp_>$XxQof=^1=>xzkId zq6Cfkm{C%=>KXw)5JcH}=V^QZZo%$?AoT=vDY;gWrpD$Ms)WEbpcxa_GiiA0a;=^s zQ)&wr4YveSHGOKdeU5gBifcz>Ys4FOMbf&9^YJnBa}+D6DDUxaXJlj8h#<;|)fj$mJpJ+QFjKlcNm3mA zqLCBoh91^_F+OvCB@)dPh5;jef@{;oG_&*cMSpMv#8V~2WFxon1N(>M)h6BNxB%U1 z$%7~oKCuBmuVkd?d5Bs;$a`WxHavajl*lO^d*_GVdH7A|&R@9vMJCd&KOec2K$ft- zE4;DpAHRF+qfb8?{sEJ-e=^Ct@f?&VYZ!Yn-PBa?CYy}RC1o+`jcqkzML2^9T!}&^ z&+Qoa0G-`;?cY|gKk}#b4X>y;-&Y?rz1xyB5umEzX>@8QHEQ&qEWAlWZs+t15>X33 z^EP$Y_kH8yHFFxTXt%)b4gBg<4K;bl=aH#?y0(a)e*@DPskR}%=x3)%E*pL!8!`sj z_Gg?;5$dRQ`~E)4gX8UZcIreJtuN2#0P);nLC)!?YTT!;cmdze&QozL(z<66s#Y77 z9xHR_f5GeaF@uItxbJa|zYVkHfBVsKH+}jvYb<}Bn$a%@;x!-dh(4J2=PYuBZVI8V z?8K$<+FBpaBSy?(#NZo^%Bu3BjfvZ+Rl)r8GVm!y?3BRWLJGX|)l1zz@+2_JKemUS zLv8~iH%Rra?DmDoiwtAd;UHruvk`Suo=}eBs@ofJV~$7O$V12Ao5h|Vw=Pa`MsjiW z#Z4~8r|Y7)P=@+5_~-txxZG`^A@D}V)*D${NTGGoR7k_xFOK9nZjg&dJ=gpsrQ9~* zUzT#EdfVe7?zv71!=RheRIjG;nDgDMw^b>x_7#-dJ97T|7g_UTkH%GLIhsmNg01`} zL5HwiA|!|5@ktX-fIz<#bC4IxhCt?mM+^SxWxmybnta zi@{{n=&0eZJ5T;3TI_L%tLtjij#6JK90B(6o45*&YTOEvl!)Q@?S{!>l@V9UL>c2^ zJ75g8YyM|BE#LTRgj}_fVrjqg(E~gb24Q*I$G?&AahM~#MquE2X&{?aR3lR^Pg&>` zBFM&FqI)fKDKC#8f>0`+#ef3Icy&P&eXB2}Mx(r4r^QFTx-gP^5%L$=h0nlOFj1$< zXhSI_U;NiIY2>vmuSNM}Z#ciEVG&hto%5C`i>N2e@=ZY14zB5K#liR| zop)#Rl)@Cb^!{uG6=$BNT8z!DYz7hmhOuB_rHVrImg=N7CFVHTz0=z*5t++wNf^;} zVp1N*iUAR$MkD7U6ltLzX@VW>1Lwu`*75qz zyRzlSUI5+Yi*n1O2$xdwW+9{uxZ$RW?666}Pg{=5BRkREj#xzT!Pb{y40zxGcYaFs z;f$jq&)qw_kT9R3y|Dug31L9d&t&FaFD)8Fr+IPyjx&lw}i>-*w+w+^RJ0R!@9>i2FH zchtO5Ks}&n=3*-1?bqDLXE2Kn)3VT*T}f+X*_f3%Qxl#$sN>chjalaW42PQ9%-iJ| zQ9IYmy`umMJENHUG12U7?)^!{aj@+!*KJf6cL8XQXVa*vHoI7z>Af^DLwRvkTmmaS zl`Bj8^UU$}=KRq%i=;o^qemul9;0wE4_l zqOLbFr47#GfZcA`Zo@d1nZcAv5GV+2)ahnS0MyKUH?&C@DM;T6uL2g|7qe2XL#7xl zgTSqweHJoDdNBd$l~d;x5B+{SRQz}@JFNJ1O~VbUj@ts2Q(BtnG^b5GiV&4Jk91hz zKJb!t#YQT5gI8d?k`aUig)2w9uJpvWqIoC6+`1YiR#!u${(ru*;8A$k}MBf31bMpzqg=8XwziOj?i>Hfy zcEsMF#_e*>rpr}tp93nuxswwLS6C9$Z(a_WCzK>@7@8*W^oNd22+h#$2$lx`WGsJ} zH4sZ~l9BRT-qALeiDRy>+_^hHkf5GQ0u+nsKpoC(y)!`grW5iOvwde5TkZD!2oqb7 zKGPk~a6($^N$=w@iTLA8tevM9c-C&KQDdDtAp)+{F-GsYJuR?y5fNZ2L15|gGq#ou ztNG<*yHu#q_oK7d@Vsxb67Tw$w9kYEXx8nqMs}Z{0_ErIjA9{BLe!iydm(OCD~HQJ zeU@`+d_d73+Qs3KYtePl?kwQxZ#%!Sr&5dQ4FwlK(n<>rh4kaVcXz#A>pv5_>9a{6 zQugn#TeHn(A}CAujeT#a0ACU!f~IMYh~mvrj-D^wA%;G7Z`0-p%UR_4Xo;pT(9L~> znabw+_B~k1R`3AlaDb8L_EZS#7|F%43&hf|S9OcVk0Q!Z5xX%J4dz*bIlo3@z& z|2IUNprglK4V-~>@uMlhIYik#Zs_C(>KCj~pr%B}Sx-`^m45S+h25u~7tg$`pKEU4 z3q_~jS?RMUs>8FjUZ2d5AKPG2lY^cx?^wsvigibW(-CIKA}@H^(;DPN!Sli=kC?+J zW()4&eJo9Dn%pWu^&sB>@+Wc7IZQ+|*t^HTc}-v*g7zIh`y#70rG;X4q4J z+yp7#)1*!m*!{bB`{kk6>C0VydaP`>>!ozqoRv$#(MkCRst#CGyKvqODU{^8M?xJ+ z@m_6Lo~fdq7-F?jsE1@@OmS55eJM8pEml4+vV)YqH9ac4TDQoRv z8v5i0lAbx_VQ;5{p=V_>-rU}UXBd9+iQVC_;cgtmQuXo!L&;`;Fc=O8qfWQi?`LIt z%@8p31(y6S$7W5aKxu~6I?yk-o?D-?*COfI4}6rl_^N2NgV_(A{C}xX3~`J)<{2b# zHxdN_>!25%7Dj&LZujGpMt+edWl1$zOBJJUKWz*CXMLio0UHm`vD=6WbGP5Fc$k?+ zr22B6(|dLuA<6!*4s$>phwDQ8UG=`1S;rjndk9%UE}5q9AXHRkeXD7Q#j<7n{>VP9 zfs<9e*~v?x-Cu^f#1Bil?^;0G&;D(NWEFvIMjL7jhgeIyeb9?#(5TmGv)be8t4^fP z65Dsv>x0FxxM#TuKrmj@mGBAb{rJhp70AC9fT{7*FHF(bN=14{T zH+CwDovd6!bN6p+zv>4$*0JjEZ7gVcl3|;|GW28c>LbySf);76Zgy(CM*HKD!0OTl zQa83rJq)4rh1;l>u0L4)RnJ?g`==!B()VAi5{Bz z3l#IO;bM6QwO#cYF;t^&g@8i2LgJW2NUSii;=)x@-W3+h7~JE7Qh74JdKQ{^JnwK7 zu#;ueLxv$cthfsx|Hd`W6KLs4{E7#ggpS9VD(7|Cw%hC3KbJYWZL5Ikl~D2JIfI{r z-}!Pw%Lm)+8BH@RRA6)V36Q*m7>24!Uy%K#702S|->J}OD?aO7(+}X)kuVaw{az(!*&Y`x?<4>9qCM2No0xU^^_E_+ikh@XU*D0|d9`{v zH6M&Zc9NwioK|{nvL~aCJJ0M(L7i zC?)KqmqSU9hL!D>OXyu5!7JBRs5GjT2FNM-5H~M{UzYiWqL%I+T7RK^1McQpG#Th5 z3Fu#=ilFjLw=8pYgko?PQ(%-|msxU4s!~-7<#R>Z2RH-JOFq@^!RdJLbCJ`M5`H`W zJ4%l)8AiL`JVZ`Y*PyfwGxqtFKO=>C(@|G0bA1}cW*OL)$hWnii!C@Ef8LML=-wr) z7wXP*%6UC|7H+|FS-=t=vdpR`ND54}H9SXGWfG@pa!wP9kgaoz)1>~*?U|Gn@rlr) zjeze{xwvUb^yGy9S}BOlfEtyuY63jfMBtgc*d!VnF@o4on8Y9!AVMOyu$n}Y0Us4k4>AoQ?-S6|tFswvkdQoOu^7WMoChYPCC-q1>fR zE@}}2Tox8d;`IC`ZkbM%fx50Li#;*n>&q{G*od!sDHM}lZmYAf zhJT=(*W*IiNh?kJGuKHD1fe0R^l4?^%dlmTuDS=qv~a)llZ10Fv6(((8!FTH3mnig zmrv_E*`=$-oqq_^;BuH3{?kf^lZMv};?;%3e^N0xHOi(h1@SU+y1#T6YriSzn`5#6UF7i^bMVd?3V%EPBQ zm^y_7rOcITrhmHF#Sfz~5=Uen7#XSiN-Ez8R7pH+$0fA+kLMFdul9@W`*F#i$N6u_p!t(7UNZO%YZKA9( z1sAbI6AqwF&tn=e@B_;#>lCz)Z4>Lx)TI8;_&^5 z!@Q-gfnSYCrTGuHU2o9aG^_RGQ&v>cq^65Wk@Thh4CM_G-z>>0>#PqZB_>9HS)kxZ zrgFtLwa49BWAjbPvT{c_+Fn>pt%(FU|K@oLaz>8VKTz?@6*r?Mrz!%^(KyqL`IXUZ zm7JLnxLWIhXIoUA+~Z_IVq(NXgr$W+1n*dsoZ$D{fa1sVdbR9ToblbmieC%e+_x9H zDrnaM{4oopC-SAGD~vJCl!o+AG`rPYP18w^W?IG(v)igTvqi6E*s?kekdT$S4`1?u z!cXb(Owdr&8d9bhK11A~>2yvgd0nl!hkWQUEP8^x;-BQTjkAl7yKpx2&v-QGnyHqr zr)wsr-TdlgkQ1y)Zf#tv#Ib6ng$#7of6621+jyM&E>S^Q{u5q0oo^j5YEUdq#IXHT zx9I*k-6`y1^#g986s2v0xL)*0&qP4$Lfy=z9d)_lFj|g>|gg~x(XzrDpB7!X}Uf$-mhW?l@^ZP7hFN1E>48cVhn{U zhO}R5P%8_t&0cD0cVy!i7GRhB2ewV6F3bQz${!TPUym2#11KEs$wysDXXvcDuBKl? z=?L=iDC*SDJQ48n9%P(DZSq>JR@kL8s7SHf>I>`9AI_L95Jd)V{py_hG)$5swxchW zQ1OL*&$INvs_*qPpg4R84s1>jp8^O1*KM<|V<|ww?DMGFhK99gd^z_uG-4*!i`0;3 z{ClZY-}@Y)#1M%kr#mmY2N5Sd-`3OR4-5yxYJU36L9*9Ae*-8!=eD0X`&~hXb++NF z_#Zz%XWj3eL4VlqK76=o^Zc;cYD+#W@~GV->bcsp{UZ?Pp#>g7<_o}Rir2ZlErgpo zeQ-vk?BnL9HC&7mT-AQrM{TO2O4`Qh$+jj+6_;^SR+m{E?iszg?XE&Kbs1tb0SENX zZqc;$bGR70GpF+E`y%lscAU=YN7jia!|!`RvAQRY{qLaPpP}O&w$WU8Scq@`4-!xhSLNiS8;0PA!6 zN9rJcgS1@lXSi$46kY!IRS$#^QNm6&i*R!-dshZ;V(~`8_r(w0rU(>+$jHR306Z0!B1fk~JCm*Lz zEw#fgf{jnIMr4+T3YwOHB0$Ad1zBM9lBaOiCblT*g$IUo;c=+?7tOy=&DBWRz1EW- zL@?+2+XuabHf_Ka0sS;UTeK^ZXu$M1-WZ>!@qhx~U4x3d9=TlSkMj$RMv5jirffg8 zoW&o^ulTy|VtIZ|9}|oB-g7A~&u?0p-4DJogv;6xufl_R{UD1HEsld7pCG?eXHpw@ z!I9{JLu|y?^|2XsRaI<@gt>~!-`O9U0=kh(hVgkCD^5Dya~@9dJgMEJllj8J&a0$} zeq6J!{`Qs_LQchpDv<-SZ?fyjGV&0Say4?@hTLswrPAVC6!F<`p=ytb`hPGoGkl~VTU1dIi@-5RB;s7XWi3V?=5R;?&_{o< zo3khVl<0h!4)4#CFr+K*=jAj*(R_Nnaa{X956ru*Ppo!xBP%M4Gq^GDSTAcUk-|^0 zfU~lpVV@Y*QqdU1&`SLC6We)Gi zrF5sj*g2*4s~zfA{Cxbrhy~R^n2-H&JYJVxKJX$`{PbM>Yl@%6U%`G5Ri|a?%SZfP zIY<)-c#FP~*i2AEMPLxvP{kYF{+RO55;%;Dq_VMyM(cq>(@3=_hMHeAVr5wTeMuia zcpQ@anTpCqs%ph98*;hjYn1-4rrJe9Zg?UODU}$wl6Wb=n&M_RH~3@k%owpB<`vI; zcp`ga*Q?N!Qo4h5zWkE!g7g9AU*_p(QNWjj^jL8;H=MvrsfC<_v&xZT+{R-- zR{P29efKzX4|Y3D^H2-Hxn|=3x*b6x2C?_2N)ts6yc`|f>1T?^;Td^>Kfz36WPG@8 zpJFSu=+8ApYR65U#r|@n<@Md`!? z`VERZ32@qXWLcd#QiZ8!otQcY2Di}6OubxG;Luf77xvIJLGMjgdEZS-t0PnFz=NhM z;`PlAbrYtpaSL6YI-=2%Cq#P!CXyh?) zfY4#N99&Z8_uNJEm=a6;$>G1~JR`pm*eUt-xs&`#>QRQ7#uR`GQp}ttEnBjt4z|=r z6EdAmMqSZ+OhT6IeOK1}3CRAJ zQ~R%W*NCE$7ZOM%Zv!_QOkHMiA@KZ?>VwT@hWDeR_Sk&|hXc za8@NjaW0(JUnWIt*$?*xdTtP<4^Rh1{+OPqvL_`=>#C<^@DW6bA~=s1joFT*q(anh z_2bSjQC-5L7M*rrGR+caXlJ5!HJ;!wUa^kaM3$WF(w{_fr)cbtovoqMIc3Eob*yJjaG12wMOW8oTUk&X)LTn@eA$5IZXqlTiE8vkk@UNH}Q|Y6p80c zjpP0b>v0;QnVMaV-iQVi_mWoMS_NFGDCxV@)CGt7Q+_r%rZ41yH3)l;-?)KmPD)xVvE>?H zTh)=Ar7|hJ3II&r>S0o-X#rtx7B;v*${Z za)2R1Fo7leF=plwYo`@8=hF}zn84EYtG!8epeojFyQA77K`d+R{5yAz<>+=@BWk0uXz4RDEZI%nm zGFvUnx?i$1jW|EBtf;zq-CEVF_G2=+Qg(Zc<=b_8_>Yx-IWXLkTb)$|#fUSP*GMevtBzv3n!QaDsNwJB(5|iA!Bq>&Bw3 zYIc)q-^ao_MK(CK7Pq^Hoc$Q!8=*)&)Ev=WAd=yE=m^kkfosWOdzpX@;_Vw%AiusV zNn(@{L01u*al=4VVv6q|dq{d`0-?VtLueURKB_@8 z&7TL2-pn=*d7su?#Ci*?zfDqN}= zN|A-J_K8NQlUGIO7k&dAGx~O(jl_)M^bou#>v{q9_M!Bqe5i0hxaq|(AqOJ5 zNK7Fg(I+8v29sLo+Yw6Xm)aQUV+_6RUh3Nz0OM7PFak!2f1+BPe|LMr_wd zC;Z<^o4{4dbQ13Z0F4Yn1qx?#*6JQnUY?nVsIMJ@xcc!Q&k6N%D2SqAL zP+qi()z#3X{T)do00Y>I-pJ<^73LtU>?5QZfb`gGb4tf8S*q+p`d2rd!szU0x9x-!N(vC~|=2H|iy!Abn*3qraw$RqTH7J}o=L>UET|2^uvl@@xD@7G9Mh5162*kAS0I|(62`oH;84)=EY%9l3{eH%si$`-88a+B_X_N z6KpoG`8z|h)&vH~XBkE#M z*q213OF`HzEolr1`YMr>EQAluohAQ`5!x@pq-4tGBubMMJSo89F~VkJOKl`(70U{d z5sy~fv<)_9V*iKccIu9XqepUuACGK<&Gt3hi7Q)RiC1TcMT{xP&qF@e^1>$T3zWRKGstlG;ejtCO4086;?1AOAgK92?G;wIZeFzkRzP*@N83ONhgJUb@uIq?f1-7Ve~mD zbl4X}9s+m?LDhgV48vIQKj08!^H;#c8McF7hg}pHepT_sP8?4{EpB|4pv~`3wmU4> z)d*%w8}q@s{Of0PV_L1^oct*fajS&_!*uCYS7!zzlJFbGSu~Mby*kC|bsqHbX1pEeQ1@!R*twxxiBC(4A>D$morcpAX}Jne<<)Q1P$FGQd`SCe2Y;O)`XO7VpQ&_~7Qs zN&;z=@3itIO1b~cY?SFh?uuEgXcsm4mZah!^36+&Y^|D910hWj;kSJ8`hfKVe|8#j zd3Sg1;3_Rby|mFr{x<+*O%*7cw_M0wR{r_G*%!iL3>B3=VbI=2qBU5Yc1JEgQ!?83 zS9RL~f>`5C4^ruOM3u7XZXkz9U?eI1URejN%J^XOXcYp=Zq`>H6%f8wEEJhcXKB=Od*#+L3x3o!oclO(B#&N}`160fU&+8bPJ413Qtzt8U|a5*my4ThHg zR2*V^LDF}s3F?@;w&vAf>wqi(W9$#HX;r9vyRC`H#RDRw4o4ST*{FgLpyXlU- zLB4}FR=>f2-?UFModBnRSe~eD(PTT>v^JH&`DB@u_N}cb%;kG~4!GGByQ+q^R_n2T z@$`xP=Zv)^e94k!lkK*dE=_yy<#9?IpY~0Q-&ge2-S%cjt^Sx2ACE9TB>Y%x1A9 z!U2v8_u0@?v5T$2ym12DX}qRKvF}cfxn<2oIr)hN!nRA$8+qRe5r`oIzW+QSX7pef z6UD#6As?RoK=02smqCr=VTe_N%vndx03OTXNMA{!K^D6o(-TSQnS5{s&w^9T&zAuH z=pFKr`GR!M?dqsldR{tEo%&#NkA%PTox`#e^;+v+&SpI%*@e$=#5{HkK(r`QcyI;T zx|0y0gO)331Sn$>7mI%BtS`4;r8j-%{f*;8SMYHNwqQI!1X)sM24GkfVzm6oRoX3~ z;>GE223|DvirOcp3Mp%8r6QQCIDROUA-{8U|AwYDe{StP+EaJ^-m`@Bu+)EIvvI|H*a=WwPR4##Y)`B8w@)5m&E5=8YSt^>#vl%>itMd-P48~1`I?3>3<5{G+ypP!r5276kC?Gzm06JGIOeV3Jxr*T~9$tg;>yRR-V zj*UBz+}tIw=5Px}VC#nn6R^XABaJR9rO*hEa$*TV#= z1{JT!#Jp1Jo9pJn7NSWOzDlh{uT&$y!R1c2t0F?F_T?k6gbwT`5~9`6vIPA z#t-%7lWQ9YC~B}Zc_`Z19%lfnHJyQx1OI(S**Yyj9WXWQRL-DEAuw8tal#kJSj%?= znBYe3_MHujy*E5g-sJdO zU5UPY?kM)m4L*J4YEJsp7N4m7Kol|!<2&yl=o-+sq1wRWYaL49yO_pE@+dL+<&!-^ z>7Y1vUBJ*k#J=rBP+Ys-(^}wg4-$wL6vE(z7~U?RVsmJD*2QS>^3)vB9%&@WAR$L za=6~!E1N9gDc2YoE%*N(|BAyM!A zJQg9Pto>Gk;6gAyyu z_Ro`~O3($8FH`@;d3|sE$Kh-%rjx7>FH(L$P0^NAi;yoCF&cpXq4X*GDK4J==b^hI zmznTC^@oXvMhpQJwv9q2hhn%vUJ=M({>}+)u~&)mzQz z!-;<^PbbQvxcfaf?Ph0Y3k!cmH>GBlt2CC?eJpwj@^q8SbZ<(@icx2f{1QHfj6zC# zvqkQV4`Y&^vEqak30@9wW$75txq?1~E?J(*u+pz@&iZNXl|mQWcxP8NK1=eXtBhe6c|1*Zlgi{(TF70c4SvfyrVBGr8N+ zOIoa(yOKwiD_L7xcUm={>zK8rZxLm$W3TN4e7j2OZULy7ON7eT$l9X1o5-h|Wxux` zz+^wrxJX2g5%mb5OX^5JfX^yjY|WAb8n>N+HPtjxgVHFey{K*1FGp?CU+G4kluVf| z)~GQ=O_^al3zmL&54?4P zU2)iJeT9Q)7O)$RCtp9}rp*=aA8K%=A^-W+CLox&!C5`52j8jey?P@)0iWLmG_q$6 zTxropUS9e=5&%n1>X)t(r3r6rUwWg^pCZa_@=&T>TZKllf7@>J((CzZifrgYJZWWK z_|nP8epvsp+08n9GuQPU=&Nal4)Ece%ob#qRXky>%S!u7rOfq+J|PtA!HD8@$_(jN z0#y~j`o7WwqO=Mi5)loyr^e*?S1%&yk$eoGQBsex{zo@ckq&o+#gYm0Ye%FO_WS_X zW=L5??V}>3!~)=1zr#ra;)NI#^WkD1`aMeI^sH*0p1mSe6bv#IW+K9zkyzBL5oSWn zYC)2ZT*2J^H1>ZNGXay1f1FJ{_N4x&I3X-WcJlSmw)DwyA0U#(WX;zC$I{e+oJK%# zudhRPU|2zjIfgJFXo%xd7@@ycvCZR*IIZf6&@*Jy(7NRk1kXzr8h_Rw^;fHD_z(R|&tNhw72KJwbEQi`(LNu60xKAYYZN^0`(nwOT#)XFd_OfLjB;H5=e8==Dh z+h_|4XHPy(pxmYja2JCG(&(eTK+tKtlrT&b#^I4l&22ffp0<18<5<4F<<)@U>%1ZVsjiHDuYp-y^_Oo;HT5khD# zH83~VOkqt$;u4Z@IvpwkNf#pXCg!XSL5}p@`SA8e@+$40unA;ik2|u8y;Zpmi$X}@ zkVXYlFGKBxWn>*S^fDM6Q#B(gQ;USA-MKML27>D9t?;L*c?OqW*%vV12oJ7r3mU8a z+d}atT`$l2CUcKFpEOx~oz6%jhkaZ*@(YOK2$#HUP4^LZ9uu!g0adSUj&S$1^N%pl z9Xx};ADlZs$j(?Sd8TGKx?99iNB6^{Y)6fhwv{$n<6{C`d_AjlgXvS$L<1IGz&pB9 z2&WkBZh@{g8L}YBrHG#_^7-EjT_0XPSX9Me9{$Ag`}4~oEO7lts~^lRhi36gS_n-y z?e^YUpMUX3Nt2`ci>tj1)DFj6MEBUNEfVY$0com?Hxqgxz2Z$pasKpKH%9I+>SJ!h zxB6Q-uAh8lC$28ucuO{bs@La)Jgct{w=(b;O$suFHqli3Sh6-X4FxPx@%Cm#vVsv< zwG(>x-g*)_KGh>o$2}=p$h$v!4rr|0>KK{B+Vht9Mp=9uwCts#x0{iXon?gR>l0U# zQ{{>jdSxQ3jax9k`;m|s$q${ywnWl$A*r}}gJ)EQwfJwa!|fySv9whPq05*gC8w}6 zo^A13h9&=@BjM&ndP1YU@1RW==O2+{1Iw#r|-Mu7YcAy+3O^X+=>!V_=WPj^? zaRNIT#rvixoNfGmC&>Qu4Zp$u)$4N~(?vy2MIi>+&MN9RmE>-%F7m4SFSEA6n}nup z%+?ZENEJ>)HjKLu2vjn_uN?%hDLfel@H>ZlVsr~GTUT6k8>GkRdeQRDrCD(g6f#A) z<U%7)`L^qN=aawAywF@{!^pbL;a<1n zv#-4Yc`!@^8%K!3#sM-s*hX@{-K^CpalbJ2ToDj!vlS2rzMWAn*Vj3S-Ujh7YW6_NEVV^&5z9sn6)N}qLZ@%SvwX+d zm7Au3-VFViJouSChvPH2;CgtkO6`=uuZo`_``Xm6a09O6kfd z>B%YS=*=wIKEe$wL8FqZsi$j4WdHoc=0!H5xpdwI|7ug#&4(f1t_`=SVd;#$^>8CQgf5q=gjTl}@6e8lsbpJQ$)V zQjtvMPQ~vwyYgK6A}@)$M~RWtqHo+Y)rlKkKmGTEOJ2SrB?r+VMKSFMZh!Bv*leoDtM#PlI#o^j?4C$o!K>?LIjebGPc4QSj64;Cr}sV{ zZwjdjWOZIs69bMINfW!8F+yk4aJASD2h0UW$9KmKD@^bdJYVrXGPQ}oWlzoPuE_N` z&&QuxUz?kz{rfQlb>8JK4?^*gxVjKAJkLvzZ()1d$zLnZF&OQDw$CzV{(?{3KD3Q} zhu^Tug;%-KU0jA|7-pDp@&mw4bvp;`o2Z8X*sz$n zdUFE+UvgnZ;+_#%B^e5Za*J*~pbsB5lX)0R=i0cz4dAnAQs97DglOf2Rm25J91370 zy@1uY9-pmZ@2^i#_3Ev9!^j=|vvvO)n|0fJDW6T-`S19BIQxtBi|J_TUNV2|JjbHP z2Kl#e0JS`y)^SSP)1zgsU9&WfDx0_pVZNBr&=7$;DVLH`cywIgZ?u z8_cr%4{maN=1^x*d5ym1Pt8JhLZW&;p z&vb4SW-ljM6lM|1hTyEJHllKbOHe{TpzoqnkI4nq5oc{g$MOcZuujGB zjQLPChFkPOXqelZbqdoM+^E~-)d`{MGC>P$P*X3kL7AkY>-W6+KkR7j8Qiw#M&FSV zy49fee z5`eaE5C7i6QV0@w@H?nW7Z$$AP=kuk8M2YZ?nSPCb-Jdt^Q^q&dUK{Ksv;$B7RUd- zmskAtT=Agd-eYLsTkr;Gw2Y``n*Rn5UsEGAoJxjoERjkS6}BL|7uUTo+pkVn$DIKX zG(;Q-31%oC@t)Z1MRgKF^1U%S0o8q8lSZ-(GF9a94NW-Z>F}5a-@AQl*^q8N3)?4f zfE;nxE{Q&=)NBFLZA>`)8W0{faScmkeS+U07V7DHDLxu-g`^H zLy58Bu}Qz7Y6UfEq;#)ElqO%H3Yn~(sZOO6|3~t|pbH=z1P2ksis1RgoDZkI8T?vjQ6Ku<$ zV>9K5kE_O-WH>{iFL!zizrQnC?NquK7rr4`&uRWc=O9M>%^pv>4Pe}w=c&v*RF?(H zoK`dnO(jS6<5pRid*90}Hsv(GBJNxo4u-=?{Q*N!9LE^I_!}jWx~|BLrvyqifXh?4 z^}`9r$ndPYX&s4lpOujuY@C`5GFv_F+zNbz6e(hg7U@BZjYCaE*7#$S0JL-_p3Lxd zNr+mFjVQjzd?+4Lh?kQ(r*e&4b<|QR_pNpF`7|bB`Vtck8q>l?#J=-ZQbPq3B8M9$ zXE=6hTn0`|VO!aQafdIAZn6QDrZJ`_W>=`C(MU`{N*IB`N+Hmy#{z9mZfGdzA61aJ zK3c*p4F)`Xp@bt)ax=EWjZ~Hqw4HBAyRX-?jZHcjv$KMtlFH9s@8e(f_rs2r`~i4= zOri&gldUH4YLiD1qmUiY^Yx^*Z*xnNKHZIW9&tLla`hJY{=u@c&@o)~b@%fT`J14-LujDeOLMvNC9e(1oEkdERt zN{UjziZhpns`h&%GMa$e^$PUL7@m_sy$E1B-;mfY;G?(|YbTrN9= z*OVR(2ZMEji)#y5o+~y&rirRni@@e0LgZGdcg(}*464VSA6ViCwS^;^)@?jLqmaK$ zX^zwN?SuIu9*)v^p6F^hJiI$zN~}9aZ6iP}sve52cE|IF%Hi8#RXc_DooTV>48h$& z{F<)lNlxP}(dC5}503^Q`vdX%VlNiC2$Kc(D@_=7C#Y@(IS<%Ei46zM7pk5tTaj== zs)-m%P7K9U7!fd~5J6bp74d`WKGMCOrONcDs()~IoPV1H+$_2qjy{Z_nnoa8<}ivA z;ij@dax}cuA98ZJZBJmi&kQQKi~ifCUbr(w1EPlzG_KvZbF;7kO}1HoUW)ZE5yE;? zI^BUcxvs}vvOZNUM;{Iq7mujaETqvO&2e-PO$#)EwtA?LIN3DzL9$N&a&$nyE{6tQ zJ-U9Vbgp*lN@m3#y6WtaHlAQL4Akc5Km{C$_t3Rg!_idl&DJ!)nRZ1m9>Ri$w5o0Q z(p^R@P1gji*HmkzhdKc1wcIn_!_d=O4G|EQ%ePK3GqpPcumXrMJ7bw5?^ZYt0}md> zOV&9%$sYqrQ4}#N>S^(E{s?%Q?(uIAc3>4YVWy@HldFh9pkhIJLCENsC>)vTqHnsp zB%dZ!YV;NSub(q_3loKN<%erB;XVycz+pSzI!1@5P52kT;hpd-Ca`Nt(P5#Q{<1HW zmA}SxIP)!Z5a8863F1$FkGcT^B#+=>it}XZ_lI=Wvn)!*Tt<-=?=>B26MKD(a_de0wlN9)nk(8Wat~(Omk|Vt4{iyrl`kJ zTXfK8UL0u~tXYDT$SD!6v!oi`_D|)FhtR)Su)aO6sv_^#i^bWx_Se@SiCxn?_Wk;_ zcIw(SD=^0U*|q%E{Dt)wImo=4=FEB>x>z)~5}%{<^Ola4l5*y-M6SmC5-`xzY(ti? zF-41CR;(N`cEqY)))ZVqe8fD_Z&$35j+JGil<;=VTW?CLm~WfF^%@cZ+kOBuKLRu&I`8l z3|4b1aj|2rV@qiI^aXe3?W?XC80la){16v>7<^m3G&A?fu=nInllMCiKlTp(Q$;yJT4R^c~en$&dFoMH`ZNE0gK~n@2)@ zBQnCDpP)fIz(Tx_JAKB96Vea#%q9f%2|ffLf>Ws0V+HDzyIgD|NurzJ@Ttn(9_1Jp zF!prjgEI!U^YoGu4cBp>z=tHiE_d@WAUGeQLk%NpnILOuJODQ-sXegWnh5c$m89|J zIY2QCW*8W`WL!jjx3t(-LxUlE#!yBe3;VTJOBlR92^`R92`QtfoAFC;=>_)?x#CGX0mgX#g+Jv znNGiv+O=Qd6S5@|98?x)FPCfkcfiU)8hSr4w79$?PGo7AD9Cx%m&6ML6Crus!t)Z4 z+oQ#Pim+TtH+KPR>29u>%jHQT&v12;*T_YE3^)fjmU=F1@x|x2-8$&XvY-Y~SURZ7 z)k+BFYSPI_)#1twjLnRoHj-{3qCulRp)2pa#iA>Zb|>rjgl;UgYgvE_OCa5j*%`}c z=ClaWi4aGe6So}$=Rpf`3aYrn=5OZ96$6tv^znSLz;pD?V#rLPS)sbgX=BT=r@Za? z%1`;1ZuidUZ_h1@9)1|i@cG!h>2%72-4$RPPwsy7@h2J!(ooutfh}RLJNWc@J56blU zY`JM<#Q~~Ga5YJ8gLa5ENE={>unHw#Q>B>YZOo-kFX&@Vw5{|&9!jq!;krU=bvxbI zq^?&e7h0E8{z9e(*wG)~mbLFGE5<`jJ?tC}1&@rw(L|L`JgD$Ok2lI{E;cH%2%n{}qmA~Mv2^@0f z3)sG9o(KU5l$$W1ib6w#4vUR?+1E}w{D|dC6R@(V59x=IAA-eO7B>k(tYJaJu%f7d zhDde4umehi`z&H|*7whmHH2I00GO9%} zF3l~iWjqV=V8|TA@lbBQpIdmsf}u8hZ@bmhZKD{jui4nCjcEue*as>>?b1i(!jLqp z8WDmG3vNxy&JC;= z_FLNaeXd5DF8G&&&WCa>Y#hb#iRJbuk%{#?P0}!xQs@|gZ7^ysu15`|8j3!;e2TKaZE+5Uq1lI#&L2l-cmJs z2QtNasXYukwkvA4RgbY5WAWPdQ}U-e9r{$OZ-Ye51f$J{H0=ppqjF-BX>Kut6M#{!M|&yw z-*wdBL?5OvfEgoLM;!^IwH8&}{-E@tU-#MT@%Yl^IDcm%Vb@I9x6o_tq8N0@($yr3 z+OFlOp7`0CU~Mqp20p(f>@xyaIZu~F4-DOaIo+xmmD2fDNGQE$$}s`iU;x6X2Gns? zd|HI^_a|p}5xZrlabA|ad@4Yn;FsIOErT~eUY2oBe2^rIJvAWxu#14CoGZyRmBIPsyK%ptOqi}FI}L?aFrF!t z$uAYt>Fk&~=a4-$0$Rd853%;U0eHu!cvh{bJ7^$rB(D$ABjUXx2gDbrW_jY z;e^IC;y|qv*$%q(lkDqMJU9$E&g*_G6`!{CKV3-))19>4s%@O+CP`xclkJ6?#4swf z_}b!p4gqDbG|G;#quLiYSMvqRUO7D}$A*!8T(>?dt8B~FL?{;ru_uRZy8r2AMySiS z+xK1)8<7!seEs^TTiQxSLaza}b6&cc#Dd9~#milwl7 zjPrGI`#v;f8du;ruE0QG4kAKoG}ZPod)Eb9c|q^;`UJ%fu4;x7+FaU_vBV|?Qjdv8 z8Y|z%+fcpGxbxoZ{I&-y>>+nie=5KDg*@8cN>BY-!v;6(G7@3ld#%6zDqMXhgKh( z{Ei4ShPtIU?k=Rd9A+VI@KhCar)NOafDWfZlv|K-aW2`}io#0CkYKOLC{H;s}8+@Ce4>dQ9uCGe5T}M#Mj(H;CfhM2l%0EotFGu1puHBk}Dj=aR$2gr(~TJAuCi4HV9iTcIGB9LrnLD<(LG`Yd zD3bz#iJyUtW>gZxJ9={4=^|p1Ds7ULstw~jKaLq?J6k~EDOMvf1+%dT%LEYCZ;*(Z zoW>gRfw^S(Y@`vF3(z=0iH*Z?Fti{I(uPObHU{8=U>r*HLI_uaH$9!LnXH-iG6}!U zUnaYfAWad;C@ybN?D+d!7m=+d*|a2~VrtrHPA4oSQK3YlEeEo zvzh<>-p-@sAmwcz?X}3uP(5BB*B;BoXudrAr2>Q=U$?2$ViEA_&lap z^e7o(?hw2b)nmS?0-vHWpRcbV%dY3LiMDtolY(X9{34n$qhDRpEzh@i{z9?K#>!uGhz0i$0yBMh4%Be8@bgguvy4x#bsZ1(H*~nDsAWUp+6*?mjS?CMhR7Mn%$JJlX_Xu;-!BaEJg zWPMvCWT8^rUiEW*A&*EE2c15V#!K_!ibFubqTO4!?h+3aUmuU@Dk^VgfeB7<0}N^R z22F1Sf~yI2sh{(#n7Q>^1mt4V$Pn6O;WQNx^x&`M=9NZL65_f4@`oS(h{D@xd0bo0 zMs5AVt#xxcCXH$t{L>jZYD=~*qS_%aOqarrLbaQeqjcscu~tqP4@oE*k2C5GD%IvHPGCX`6d*7d=tfoxUQB|tTv7O-K>WYod*|1Vb{;)^cv@Z4wKuV_*C~>PTCYELvE0%rPN2#2 zhwYM{KDCV7fKde?d4Fx6kGZYKt%e(FlhzrtdZTU~>o|jftDV5?Vxx9193X_#ihYoE z4gj&<>g*YfAzHsw!VQ-~f4Hl>tfN&|JDo7QHA3sWGz3+edU212c*pDQyn1MNGHfwO zDx3hw*lk9PZb)^s(+UYMY4_&5I)~_d%l$H>G(T+5qB>L~>BC?BY*jKn8J%^GMh>U* zNF~U{N!xi)5UzpB*iPE~PyvxI4OZ!Qoke$*&wMzuE_p%$GJ)%pQ}HcYu&7oj0ld~p zjtPmzqd>&!AnTn5+N0fB=cM!5`RVluvomkR^_Cq6)A`#dD#D;^>}oeA3Qox|8^o<# zZh5(tlJv{PZR~rUs)%;kUM&qGPdCP7oqlSmoy{6hIAPE-yPnMX%EMOh<6UAlTONk! zEtRt-MoaekcO0vNt5dBmJ}1ENw&=HmeCh740Kq~LKriz0+0i7d3+xtPXh+}L8em<; zwDRn%X#egHqXKvzmAD@Nr09a*lPIL- zG)^s*I)|@pk7CkQJZ4<;Jg;?S){vlZ#+4GJ}ydPe~qlYpn&xapLz6f$2 za@1J#?$8FNlj$%QYfUvJpo-bnsX^;>JUoNa(>ix^iZ_lLerrOaQR38xgZ0>ez{!y;& zPd5I4?>wvk&DNM%%p%~*lND7(TiFCbtd$cD#cceZwemT=yfY%FF3i8ZYE^@G37KIE z)BiM{H@AjSYH3XqOx0|)I+Y)yMAdo7o)V@o1zfuJep{e9vYdG&S5H08K{WWpy zimAkCZd&1^b#-jJ6Wo*vy6(zjbuHTbz#uml_=OQWD`jvx)1NfQmLXN;??(CE_5EDQ z0KcQUzk65JQ4C}tLp+%v);{{GM?euTcs48h4N!%wDo)ggX7fCO98abDHQ)uZkbn$@ zwbZ$uV`_?-LDQJKJOlWxr{(Whw@s}g^>=Cry}A#$`8$Tv(dl=dZaTqgv=CrjpTCP5 z)+_L(X$DBxFx~k9J@^a*=l9=2AN&Zkzz7UNYA1&0>9TU4|0Ve}b`?eD zgP^2$;u-L8T?l>?3i?0#rtSxzgWN6&bL8}e;*pz zjA9LO4+CE`=3ee>n~jfn3v6tM#{J?&bLoNX<~x zZS5nFekAkWg?rgFyH7(f@X`$Z!`pYs7mvR2u^Blx{Sbbzcue;@-`OxPexN3}L#Y9Z zcUQjePNm0aff8ye?D^MH*3WMV%|1ml7}BbHv(KwrZAoFjyHQDW6mVs?-^TB{@>q z=@RBx!NGq)^3h;W(3HIMFiU?3Y_@Z)qc9%__(PhXKGy2Fb{c0px-P7-@O7bq$9~s} zJ^Qz!U#Z=xSiF5KyF4LS8*)QWcw>EYiTprnz(60fg&`$m0Q4ZcMqGvnlX{sCm0)OSlm!6T zw0uM&mKGO}c#J^ctMo)6xc>*}4qBl7G(kE@4_Oq>C6|&FH{MRUx#=ze@J8eyFTBt= z9uAmfrcxD#{Q>tJj%j$h3?ftUdLUEpbbHAS^|K=jiCAH>lQyl8^~CI>#=@Hlb3_ZP z?P!p>df13$Nq`MU?7o9Gak8O%DxSYkR7q5hhcy z5KQIAAjmq#b(n|RBFgd=Ji!FXrHjnp-%~-;SRVjOll4HW)Bmr#GPz&PaXGH+h>(I? zqWeeNIIWAj@i{2Y6?=GkfH;j2 zBj>(-Wapq6_K}z~Y7?mO#o9H3AcX`5S)#}el*NSoe24d`2uVFfKhyW48-2_~#kMlT0xVe&S&i5#m z&%H(tDeU{heS{Ao>Im)gEs4bVtSt&+1tz8NdAGgARge_kz=SmZ$j8t2e()px`MiWl!$ZVoT;Xx63J zjTQLyFnhUs=L*!6@>Y~(c~{aR`m=!93i2Pfwohg3RsCFYhRxvVbsJCm_;?B3&YA1^ z7tVcsW~S8q8se9t*q?$TJERmm-ObDp4BhD{CB{}i11`8{R_~l;C2)Oa66{*a>6t!U zA*0@^w`u;USq?BSl}cLE*cwI+s9RG58aLj?ob!YX4`Vzl19$u9dB_H*g0mIwM}6-! z=Y<&8utb-tA}!HrWY!*>FaI`^!_^`J`nJYbvm`wlH@r3_`^EDk0j0hbaG{zszFux!~E%QHPxyxAe{ zM`&Oy^!!i{;4)rOhl7!ZBDHJ8OI%ABW&M+yHKvZh=f42Sx|Y$LaKtgE6=dWsB081fa(%!Z3%_V~^44k7tWQRno8j#`pHfDPxrCm)?n8+bCImsuyhZ zh=&gmHKa`0q|NYS&c&g2B1ZC*!K0q;NDoy^nWg6BI?lmGfMqitPBcDwK8PIbR0IeZ z(q7pnvu4kM)^R~{Ex~?@cJO11lhO-(`si-Z+ z*zsNjh9GM+Hi=iaQ>13#eXaW5VB=383pe`g0|4=EDi^xxNLcf95x3H&duoYh-zJ#f zs|D87>&lW!bO1|(V6P-Mo0l&2?Fdf~MZ?8I+I0~F#xvQ!p7sMXdk+w!T#-*)}PNnK2=KpHUZ-dOW*Yxrw#*8Ex5OFKpo< z6%Ebd59%`C)F*wQU&%QK3z*se@XHO5a^A)yALbHbbYC!{zSxu;W|Ka2Yq$)Gk&xpH zfCw35Ntr`)Tf7#RuWw1czOAcRYMrUntsGjLp~*Xi#95oY>(y3iULs;c3Od#OEW17i zx$HNuGZj+559ssxH-~;(r2lo`78#<64W89=+X!ri)R zGfvJoa+xIykS^PjAWlf$TocncY3xJE=IAuJz6;%=B_nNx73*6CB)#SrgY|$SqVU+M z*S?uxzmN{+3S~C~^VD;25uegIiOc89ss~G(mJQfPr5@GO2SnsFVo38Cgle-1zdAy( z*wC-vi(spNbM(*WFVa{5t-B|F!(fD(mqdqIU7b0lTele8=W+_2U|mJg%3|Hj|D60W zANQLd8cEG~$!_(0_PU^JDCr4l*)lS@Jj7}?trmLV+7BzanIsBt;mjq%MHZrhxN;aX zVVCN~eE$w!1Nl*CCj~fjG2B&vWHeA8<(9e29NH`(b!Z;V@Z~+oc`Uj_d@=vsApJyW zGd$mj8bXa0Um^q2t}(ge@^v+ z#Dj#Pf-}7CO)d8&gAf!#IKJ(z$vf3>aE!D)LCtSo%^+^rPV*Hq&66j=v20z&CxTcM zAQ^sGN;5`^leB7R%8qzwm18!$L(SgokZxuMKDtQ5m65~et>Wn0mB~zOf8@Ec-E_(i z23J_dUib`|0s~=(Vy`(_)Ws9v!D^o|eQQs^H&XFLfOP%Y)Zzc?H3cu$+kE(=>)&=H zN`)M8hX2QlhJTcn@<=*wHUdP&D$97$HIciC@q#)VRT**V28N4A)cJl)M3 z+8(hwMaUpy*L7(zgZm^BVeyKB6%VG!Ua*+7PK|tQfJKAH^rKM_88UC8%L08ybND z+#MiNNHx-0g8@X1dGn7RYQm7D;VDBNI%J@Ii6P@-EPOQ_+i_59vXxtOy*U4)uJ-HcCZwo3+oai+yr)9Ir z7z4>bFin#Rrvg;S#)S1ZB{NpoE5urDgk>;n z8}GyY{Sl|&(}_oNG=C@y`QgmP#YMZUJ6N?Y_e*CpkT`D=lh{(to)b*Aed}lg?yz&= z5a)gx2A~pLe%;gn$7|IBu8D7$aTz{-r7cv;a9Cv*qs(*$-9g%E!CoSGB4lzX*t(9l zRBc~N%ADP>5Yr%F5>w`2gNZ4j6yybA{NiE=CbHfwX-H0k+5OYNXG(?iZd)|2yBu5W z#Tr~xmrrN70IW|I+ju$Q0=Tc33*|+C{v``8_M$bgb9O!*y~sq+48IOrO@viqPqX&h z(l&D;If{~8ex8hcIJchxTqEnf1jc^ZFt3WIBd1V266AGA0c-WB4V`ip41>H8YwtAf z&G_{o5Il-}x-F@o`MPxr2>Z|+6`?*c#;jU<3L#{!yUg94 zX`^&tzojzuM$;s0K-K~#o}iOL6mzIu9fh8S%dO)-n+Hg~p2*}52V|9UZA9xpra>x+ zmH8Z5%6G~GMw^YSudhQsyWIz8Fq}V{Fl8Qxrn(W*%Em4A!!xySh&Z`EGOxDz&e1eI ztbBPpr&R>jaC++EHtS~W4h`1cY#`uNcXfVkOl-QqvTH9_-K1Pg(V}4V!H@vP1_&89 zvUevJ+Yru0&X!KrQ^_jIY}>d|7#vN8nLuspNi91!sp@2PZt>bQy+(ZGr_{!NPWd() z^>maltLL*WKoHBK6?_4yi&nZtaY%17r{Ea&Ix0U-KuA}MY2gbz+*B%JX3S6_A;}>q zj)6D*9#iYH^_fnYwy5>x-@A(SedxpYtNcD|qRX5vNA(Qy`T}#^1_58-RV`E77|jZb zF~*vKie{Ou2e@36UpcFn+63P?o5@ zSwv!^MW%9g;b{?ml@+ok>E#;Y4&alZTBIoci;2?rXqUWM%_#ba;(ol5Li}-FOvTYw4>$lunS+f?R?iz6} zI&LQm{J&RCK5MBt3MJCL`JjV6dJi%}xWqEg00q2Hx8?h(`>Qx!{RvFI1N1%_EY!6L zRG%W}G!LpNpt{I5bpK_R;2{Ov7>ng|o8EF-Ms<}dr6&2kC-jQz2Yt*pkMj<&K4rH7 zV-G;oW)UKGeRmKeg518vkieyz(RnB|+U46h*cZY~FKRaG!s6l0rxoDDFw}{>oFp>~fWpm6PKkl*kd9Aaav4>6dvJsu);kloLv+7`k5RNwS-UE zqDf+^4aZ4E!s~`Lnm>pu#L3YGj^&-sHX(z~Nwa=ky8bB@ISl!%5V+z#gP{qHNEK!m zfmg1zV12N!cjSw2yrt{O`N`soKA#_+5mf|%48;(BZj#qiqc-Fz(Z3A+(D(i9oGuh& zI`o;gxaG(v9AybDKRgQ&uoR>v5rCh&m|b6J9Qbh0NxJ|#T)+169i@^$71j0MoZHtF zmm6aKA-0uf@jtZlv6qI(|BWVksVXDUf4 zzyXuMb%y(6FsR1sF$Jp5*v2!35r+e`<(l~My8dHC^OWGZc`WQu0GG~N;=7G^JyT3;fu{C>XGqe}nH3{syX`|sjS`yHK@n=e z1(P8vrrITbEPa90Rpq4K5kLGDT?zQ>pTLJo_Z!`V)en@6cMb?$W@Hm*3L3OxS#bdp z3?{^nXKoD#WKgy8;_L!9@55|JHg9#rFdXnj_1(c~yPr$?33DX0=z!d&h-0D!tRW!A%$1|P9kv8E$&Ry8ad zXo{dCcR|||!pI5spbh3B+m<5~WVkdNDlPS?c{2voDHbndA3g6Y7PY9~Hp3!JVIDu( zYcz>s^VR#F#>c();EcrlVGfScjJxOl6y`bcHTF9<8}hdg>k7>NDo-3*yt&2 z65YTY4bqY+OFuS%98}a~5SkLUM3$xb7Pc0(Ut#+yY9#5ln%8=9BfR%kR+Uo+P1*Dg+X-N8fR#lwJ>gEZ2|Mau6WbxtNu7Fs4w z5Iq;)uW#G>^_!Yo-f`waEEE2*k;~dVI;i;dkYlcu9z9IMYRr_{^6EI~y8Xx8G_c?J zkMrxIJHAW+D$#&U#W3-jPw2>pP?2f{MW7xPQ(_*H8w5as@wGw(sqs<;Q@d}afjrX7 zM?N75B6ud#{`CM_n*S6m#YR#lA==EMEJ03afek#(!i*Z22?9k6(<5o6HjO&mm7D%w zqpR$;A{};({iQ_?i^=6sn7L(pk{PSug9gM-(+wA{idS%PAoS!W`@Ta7QsO%&t;mf}(pMGb-malo(W3eWv z*E7SvD&P833yGf>?>0MYHCRmSqFy?>%NO@SGmXPXlI1`h+{6>eOgzD83uG#xZY)w* zghfqTC%yD{=aWYeWcdrjY3%jcfq1)VPlWGqUWhRJt_QRO<*Xnh2$zz?O{O>9l7d(m z7}8-V1eh27<%q(JG6-Fegh4|J(9xf$DM*jIjPqS3^!hGJf*lTt-sjaS`sE9?t(3$y zvm>9@6P3DWn!YeR2@JJ1Uki)5CJY~Owf|<%2YN%{HVwQdFTjmEk=KuX*{UL=>ZVU$ zh|9*?w<<{1?hy}U{EJpgEmEH5xU!dbSV9e@njePxUoox)es~5If6f`4`7u=kUvh%T z9P%CBF1Hmie|hWM2`(S&-)MQ%BIO;%+W}2$6(1aexGojz0UFEJm_hGKgx$5@K{8Dh z=BLWN85V4sRMlXbN(r6qT#>A#lm$E4tidPPF3b{vc4LqjVcrEb@9}rkg;@~Rdnu#E z*8C~9@dw*_RyiFWZ?cq4g_tn6N>YD{Vb5b zK1HBpWA0)xe*ae-n=32CvbAFHO2qOixq=+jhHPpUb9mO2VIe(Qi-6AiW^{3Gqn{&n zIOIoK$SLasx*Qk7^sTR-ig$oiNAu#5j|_1b3hKydEYgtZzwnZ*h>D8h^5EEotex!S zsW9DxN{)g0i#&v|@4AQww6^y`DQbB3N~ti@<-{*D)pR;poGj}LScHu26rtayB+x_( zq~Q=lokl~va#~8l9!O$nkXg3oP);vmzn8cz04HJe_m7t=vO-5csdBs~R%5{{?XzY;p8>1b|a?H}JWRHws?W;ZaXn}4H&bcb76DyqxE6G`&F_*$s=WG=N=@d06&zKbnN zsT-&5s+apdO=JBZbTFw7I&U^ZQ5{q1V(~|JvE&cqm`>NKSnr0YVs}@FCn4Ks+@o)O z23ZP}ffcO&YBGK_ZgYQAkzv~%BhjigM8gp!6Fe2m7li<_FkbFCxjc{}`}w`Nsc3dZ zxsz*$fag+_KdQtDh;Cg;mh*u%QYD>;3DTi5Kr~VQ= z%_gwN4kkZTseh&D)NCbxL+#UAZgAoZ_f_rEG4oX83xIR?oDf<15pmumCWQF-!-b*) z)v``{=(!WL+Ygh_tG#XvRUWUczC`@-l}eN%*& z>W@haHIVdD03<_Ws=3j(43^u9lu}dp+tNyIYy&6R7Avkyf0lase`{Ks|13_=XR{f_ znNF+o7f~SE;Ebnrb1i*R82seyst3aL`A_Rb{|0yRg+Un&{EvY4R9;lJr=EdpA!O9V z#6RA^U7>2cI-NG%;23Yi^G>`>Y+lF$Xe6jcW~x7A^~rQvAL0rY_D_42>T>EKoUZvo zqIn`3SbBa|_Ud}bYHYSB{}Wwy_iJb3umYS;efrRyQf2mg(|@>>ACrrPYKhcboQEGF z1<+u3l`hW4(C)Osm~D#|^%FH$JUlH-b%s^VNU6j3anrG~d{e1Cl8t<*ldhw(8sYp} zuyqk<`2Dayo%mXa%IysIX+<=X6lCMRX8(giZLl>Sqo|F(o-$`{>Ye`dne!_AIMr4m zzUT7)AF#qV)J6td#Gy~hJEe@pwrrrSyH7>);_|HP8`(VYaVhfS{? znbn5%`=%`$-Ncfd^o&gOZ1jwbjBJeT9PiRfQ8Nsm3^Zl#JE^4~-YQj~3OHqxt5;-n zQ~t{Tqev(+T&FaWC;z=p>r5t8#i^T>Y43Jl3WbQ;+*0RoFHSz4IgX3tUs+^0oJ^>P zeJE7a#eJe))e4hB&mL1(uRUwsI=F9bk*y?m#vL&ZaS6H$*=mbRii=6Mr&f37{xYuWmvY>sc%-)?T!^9M4dYx`9_ zhmpZX$R+hvJsZJv%IKX&$X+AhrIx@b!+cHAI?+Iu;Z-h^7vaQ+UnAh9>vJfKG2zaSYW6ZLE zyZRI1+*mF6Lx#kcpW6gqSi&VAUz*IRL+w=f*aYeR_dhrj+x~hTPv+Y98Z$4z=ZKfR zVnnwV4vzg({y3SJ@XEQj+ic81m5-AH%*MdM3F0JtRJrVGqed4*|GmIvmb}D1dlY76 zv+!6RvxAK}LJjqp1|O6h?^pRWvHq zbjg$}WuqX7#Gs)~Y_hA0f3sl+r|eIa!*>W^Q|z!c@NpoX)*QoJCuO;s6=WMv*>X`k zDCvoEU*}4U#ivo#0nO`jn^lM%!3fuyk||c~jDv#hC>b~o2MD}y1SgBI2)C?^487l~ zQ&%^_C!siFkFAnzIi)MVyLvpVRsFXPNV)B{I&)=x!!zFjwItk5x+?RK&L1+b27tfu zgV@i-C!!aPGa|JqvWRk>smt3B{aBv%uJAe=e9^NhEiOiD1E<-Jv*WE$q|aXp=Kt@6 zqQ2kAL-z@JQ>QxDA~bq*K>HVx8Cs;d+?0@G(03wHuS)w=j76X(PrV49Wy5~k7ubiy zI5dYJ+SL;H!I`igT2*2gd-*WE-R*rt(L}3Gv8YbH`ur;}Z7mbA_CZ$A7l8>1K_UEn zPywb}Ka&lC;P;~~loqhl1$r&0LuYl8;D2w?t1}7%{8yJVIVoxbVn_`NE0Qv>J++VwX61zu~eM7x^$3MmfFx;YSx#(WEXy>r)v&M};@7?_^>p4F4 zItUnJ1YW>85vxG~mn){mxId=@pN%Tg8|gnZYZ;LbPaw(wWd6*~p9 zs?U4~e7Xfpb?7P^mE;tlS}w!cCTNPGOP!J&q6V+Spx7p+lanxXvX-i8!W^b%Fyzyz ztD)%ahCA|HD4_@;Aut@y3->*^N(kf0n)Cb12MH)5E%pZ^YLi>0x0KxP9Mo7)xR8dV zJ1zYU-dGyqIw%*i8}_9~sw1GhE-_If99$mOp!0fVIq*nFObVbQhr#eo&N$VPm(~ZB z2ixCTHiJiw7Xo&_S>NBXHJMs0{p}@9T=vsnUM0Zb6J$)U^vI#Yy zh3=?L>ab~rNAP96iJ95`cAgSvob9H7bq#po3vb*~NF;4^Je@QXURw8++uOZt zNE&HDnK;~rRYBWm{AK#2BR-=d46E@Ysou>bu#AT$gHkE@Q^lSz#qI1ie$ny@G^5`kgT0`&-Zrr2r4 z)vXb{FpT1A70_O3w!=v{LOpTresuWbbFlNVLFK;M(-zJb$!2cxR%CT_F#)kJqFf#~ zmN(I? zU%Ggwd&2sU>QtY6@N^xa&5K2ifAK2jwM0vN-)IzmZl1d#9Plt_{fl_Qix2G$D@bJ6 zNMEF#2JCDV4_U)4It~&Bd!Ogz)+k=UC<`{7hBFi!x>pCy;0Uxp8KQjn_v@|4`CDC= zmE_+6+}+p7H=Yvj3+e{w9baW97A37r0?1H8I)x2VKaCJp&ml=5Z+k`!D$RO&h8o4U zT(K_py~59abH1HPE0fp?Elnhpk*l9`H%?0E#+loud{S#B2Nv|ii^`Px7xv1-NFmLM z&859UWRL2aR`3h(+<1ddB+Dwf;A1-M$#ZJWX?#^sgq#)&HM4XMvM#x)I!9AXpEdMP z1udZnN$7(S9*$16;?bcXkJ`Ro&*Xc36gU;I`%J4?cBF&`&X{!2-dnJBL{` z2RyYI4W&DUxY#8>dp+?|j=zm9C{kgbCo;}>aw*Ol6WTo_0&u|>5}mo^9g7v*6vaxp z1<$!DiJcAL`Qsf=Y3_jUxUT6vKWlqmB)OwNZ9xYwFrn>nCpVFZ;vO@>r^ia%I0ymn zFEX+^QL}49uoUy+sloxViGOffzB;v;EsWYgKr{PYtg z!n`Vi^$>*2%MB6)adETjO^Jz+5_h6%#+3hQT%?-#S%t5*$|q=GLRNDI`u&%fO-}cW z18UbAN>o!;J={|2#|csdHG}a5yS8FDa40qSc!A}EM--4UQsN+le9O~7_z|YZ5I;(>E_g} zVe-7}ueV4pPyIXy29Ie(fZev`1M5XpjIe6n5Yu@8AF8Nj#lD@FA|!{~@OpA;6&hp2 zZRwdqH)u{~6%+*{B2V@0;WM-U}Wu~OqL)H&vx3dnQ2+y*cUI# zXf$HK!G#~`Bd^+p&~iu_e?U3B!Nas~d|&W}we_of1!C$FOP=mL=943hx9_zqm(Q86 zw-E8o`Z2#B$^&Eekf2z9*?Sw|b0+aM9*z+Li#Ur?3KPkga}j>zC?D z(`RI(Ye)A(Ea)sQw2-A(C9-*GHnjPia(UaPH^_?Tu z1&!a+LEbKS&tJk@p4lj;#iNhBmb7IB3}7%6s+tZnJ?Jx_a8W?vIG!RkcbxGdbt8=+ ziL@Xn@Ieq-ssV&AhlpxUVB0hY2!jU>otll@8kjVN_K9-yaWiso3NrCouU38eS9e+5 zA-2A-QEs`P;ss)%`c4^j+k~~H$+3L6;-+;eb?cj( zRoxqZfOm)Uxs>jc6aF4cp%w4wE9=@41LCH1U@SBHed$i-)lFaDa$$9@=h7W~o&^I1 z;HwxUin1J5eZ&yVS6TQ_3PTi}K!@7{t+5vp&;moyu3bX3RuDlD3!Tsg{a*SZ7)fK; zIH^U+*~(<$a6z9uGP;L#vaRR6s@tl_E&l81E2rSqHjYqorrvWKZm`fOI#8vqjB-xc z)js=#v&4;Xym`0(&th~ur-Q|#TD|XB()t5aUWTnq`OY?-p5~@db*Zr3=p*sCzk8Ba z8|%&Ow;{hdIvaR$&i$zJ`8B$`nbXTq6#xwzJ<*(7acHju=;=s^aoiMDhFHC!F#3F( z+}$~eyAuVL;pQo+ssTk?5`k1{!%*~nG1iVs=!#>Z{tpl4&C|*z%3YMWuG6I{A=&h+ne|L z@5_qTkovp(2N*Ic2kWjfDctspd*)V706r(7{z&lE5M%~dzrX!2WONhHvGY_hij*ic z8k~p+Yr<0C-YT|#zC~IZFRGC%b<*pF3Tmb`(IFh5SRc$}ZUPd|MV4xK+7k&6el|lk zxcTfb@822BG7?g?jg=scZ1`}9qf>k?;$&kw8*X%-Fg!_%%7(Ts~w34Bbf^M>7JoKkWwBL9?c=%IACE&BQze#Hx~Aa}v+iJH37{}Sr?pt5xEa`Z89*GYdi~Ns)JF?& zhTxf+is{D#+2{NHF#?i^+_J>D{~%i?7(;lLq()M-F(s!1t(*_}!<%-%z!Gr-+|O{s zK8OjN*Nu)YkZ~P6AxXKD9>LuW#}iXDa_vtwWyNWeiM?>lqOFpUM0jxD^LO9g%lE@zutKuI7Lu^w9916K;NQca9BkW zhbuia!HONi@!ottOj4&f@-<0K{C0^o&^n*#By+ii@=3K_A3s8HmCP&}fVHi#ysT;V zrDZJYhoP-3AV1$?!KZc{yNV*)gFZtCnU}zq$9wIgJ7B?mQKl2*2mgDHfX-nj*NTMr2Zm34Ruo31}OMa#t@`8-Bx7kxt!24Ebe z)PtN%B#4B=MGt5}3aEYvD?D^9z*kaGV#vJ614im~8Y%lrKv+#McxTc_ zU9NOQSt+#QItLk~amB~4+O^SLise=_ZU*mZZdM z`plb|Dy7a0mvp5~*dWx|#q%l&I+UqiiiqE(^~5?r3fD1{Zi6}c>(f1}DYo-sgRe@+ z^ylFf8oJ+6Pt7DjaZWHgqvFBJm6s0-9c0zWsqKmyk}_JxI~c>05=TR-*>jFET`&Rf zC)GzaCA|0gSS=#nB?m6tq^SfwvbOZcYH4AxA-2D@Hd8I=2cbHzF}M5sRz6L8Yy}8W zJt*iw4`wZ}g=ZDr^9~q;ViYTSNWdo-kMQ6AkIUcr;W=eg+zF+$oqcGF#hl^<%X8b7 zHaH_ZI5)~Cf3(853>qJjyx!VAImzAsmO=ASUkV$3nq^(%74{$c&n+I^)(3%Jn9cMm zJA3r%`v>IkI4%M1to#}FlSjC0=z^oCvwq5R)IiX?ns+!g5RnYJ+Rg#b=x9u%_ANdb zaR!a)uCa%6>j|DRn_{NO@?p3c;cKBOd5dA|+LSOI|1`K|GLt{*9+x8E#x; z$lOJp*aib{E+33H@DUI~I(JfEc+SREXo8VVXy=m1wY)a#ARga-37ArJ5FZou;qHv- z-nI2OK52i3GZHooSmyOd`}T<>-O_f;jqkfaR`jd=^>!>d%@ zL5M*MbV9q*s)V6MZUg4Zfr?@w|BF7PD5+qOSKAxh%C&HBSgys)44NZ`QI?8;=h1-X zTC^|Mz9~kyAJkdPJrm*9#l+_}8O4G<+|mj@V@UE0Qk4dV2ReUYd2~LxyL?pz9T0#fR4|-(kEX)9@j9RD-h13l7 zL0i;x(>&^}5%VD~s|loLz)suy8pV$ekOZi>ovRpj<*SF)wm5EO(r}-ph#&&cNtxTW z&db3i>%!ixR@pK7dEVCb-Kk57LZ1>=2BAleEooW*JM0iyeuchml{3VAb@)sX^`x}V zzI{J_M3jHU#JM7pa zxw7NJpGdl6-jn%CLkEdNTmki6v@-`n?fQ|bxkSdm*W*GKu?`%$IzYi9W1<^kmYB;k z`63&sHBrw293Mgu&LadDIL`(|nzYYgyd`@t-KHttCaun2ydo?~pWe-_aKlk2RBC(o z5iske7Pdr6jHAjH%hsS1V$>A9A(K*U#VHkUy#Nv=3S*zD$d*EN7PBurKYSNI~JW;L`ZFIislZ4XCC0H+oe$hzHF=IHZVus zt(=^UW-gW}rwP)A*l>n+H_GoPccJyj;@yS7!(q zFywVvpEs@EP#5s<&KEUlaQ&o8Sv!>O9Zj4FjZM112zV=4rMaU1qgoPYaVB1aDYnCU zIetwButF>(t318)Jm33~0Sn?A|0t{s9Yi(Cr;!;!B=Xl5R`<2Iwg)+#Sr?TVt|nv| zTaIB_N{06CB@5GB%4+wev~?2nJo4|R*h_N|D>V-j3qB9RK%5rmzYlKOar2$@RxF?@ z(=yXDSqViyBi1gB)o*zzmHe`2$<@AEM;br87=w9e>zAi;O1+c+*)?wLzB$F$UcRRb zZTS^Cmf%T>2^(8(L#E{k+(SuocMOMbt?RMx%nVjHkv>BlXJ6B}6fTrWvR~Bji)&J% zH~q^D-&9;4`fr3II-s|xfRZfaBAi#}d9awbNxym@eZf6*r7Q*#9R!xIli76nj#UP& z2^2?i%~KIzK`j}Fl0ZANe%0LD?K4xf)no9)1{7cBRBmr7S^{7pK^Y@Q_F5U|Tphoq zYLgifF&mOQmyL^6<6uNnE_&bcIWNTdKidA9XSGI4_%ez7*t6UC6WOuCSJ~~_w&~XyJ zYTGLHs=(D#@LtVRpJOEC2xMX4>%+(Uf4I;3g8T9cy)@Qvp6Oqqh)Fc@j$-&%^ywqH z*p06MI(2^~Ff49Ny}t7Is&k~H@oTvi65*ryC2gU+A>t@(e2JBYF3?OE?NZfI^^0xt z(nwyMdx1qozo@fz!m%a4rjG$DNF>gmDV81=-7H69QJdsMjWwNf&7so@|EP_~Q(zndqphE&yHUWAzg(hPh z;6KUR-*OkQ!~Mk$#b?2ueu!Z@!9*X5ivNW`{swj}!A<(^J=DLn_3Gam4mydI1dCHV z^zdU)&OIvYt}1n9JqIfqPchzwTD< z1CFv!IdUdt)zf|8p#aPDRvGJhd!|> z*Bd$c8>TqlKNLQTefIVYD;8oddpM<`#oz*a56M^&R3d^oxDHSM%jX$cnaWDfpS2Vt+B{!lQ1S1yZhc+9NM;&`+25` zj|)+SK^t{D-I1;XB)%5yRyrZ1bV7KR9SwIj@2fOSjj`s*S?|T+5S!Pi*x`bi`E3Ni z=(Mj?IzdKaGP+m>QaEYe0ljt!4aWTUc4iEd|JVdaMul#X9Mj6Qn&z9<@r@jznJwpv z?J-I#Y!z=QJ1T(Hkq^&h<`0Y-$WnU&wgh_r40)I*$m7V2@{Am8x~fz!nrtRc{gild zr-0_|5Rm!`d&u{)KrGb7LcdsMUd>sWbLH+@>(){j`1yM+CESkxgBR)UG-SHj*AW8# zhJm(UjdRFvkhcL_5jUPjk6$6Xj%aZ#&!OX`Pl#G_h-o?K)pEU?ZxRTQzxRrf_SJc- z?UAF>aPkK-gWc*iUEFa)iy0|{Dc(?Mz|Knq_%bWCcIS-_7216vj z9AhS6s8|VES=%wSEJ})i&N7H4n{{4N3H}D;(?cs1(;%`>siU3({g`7U4RYjbxqsf7 z)D>IQ5f!Nbo68s#J38^AraVB>Z9LEu&abVwrqrvSb-%!K`)jadDKg^!-Q=}eXxmG> z(7ZDgTHoa3ems5(>*^4ZHS)mX5hh_=d57~?Kfl$LhcE2gQzys;){2oH6Xq&L&E?}- zp5}l-Kq8fg>8SD?uEDix6vfqb)G|+@<>XSOfF#~7RGtNY#5_e7@HDk=H_}jhn18Vk z%Cj8%XiV+7Qv0se`p16t$-dO|k4MRxFInjsns=zC|8#SKl_zPCP`N{9@FG`hA?IP# zAwM}%dcxl$r0=^l(4qT`#oLX{#HMsDJN;jJ|$GR3q|kieEoFGzT?Avl-ry+5Dhql;!=1+)$p)UbemoQBr%9RYdT%J0NIH$=cW3Q&#_ z7>xcXK|Y4{9BF>Eka;|YBNrAZw_pML@IR{Wt{g9!cCl?1X5tH5(;V-VsrXP*I8+5T zffhM}P_f{a!1y&MY>PsBrkX`QepXVCtfm&XexA~L7LPb^84!h@tczZ9lVs?tb6V=g z;4nJ7r%<=7hs=2G7_}H>zDy}g>dc}Qs_3a$or&);D%3r@XF)e7kcu4mjzzQ;5qiK0 z$U#1qU(Sy{{lTr5kVGx~#y2(_%C}a9e(`EK-?^p|na!h#StE6O_Edu%!Fet4QRi9{ z01+*+e8{RP?I@K~6a6zQyV(0v$quHW;GX5v02Yuz_gWH#%9zd9GbJhD>idN;}H7IYL!7fB3rQ%i2qzxXe zP^7}!C#06)a%)<6bKBI0_xp=hjnJ_!L$?ry%Q%=tR|NoTY?*XfNRx1>8g)DgE4Y_B zkn1sqo)WU1nxOvnGumuEnFQPv>*i zq2jtv#+L({pYP?GV>*&H@%Z7izsTZsZBPKJ9Y{YJj7p{bV9xbJx9L;F$}fsD-sgRj zD21+#Mvvn!joMhZ(A*yQLYb=8;ae9&k(3f3k`M$P*)$lhCPdb4yKq18;9;$(R#B&3 zEj5=547k1nsNfU;wuR^}IzRfCxufzs!uQ|&kMt)b3o8Pm3wf1PWKQV7!Z7!{?R?s^XyzQo;hbGVe|_BV=(nc3V*8SQhSyWINhu!w8;b5nTS=znq%{;Y^$ck4QJ4bPrLeH-teaM_Cp#%V-ezy? zZOFh;4B%F?0)J-*_I;=#o4Z-L#jmOeFaELS)$_=sG`;`7W460+RNr&1Vj92MuK(|sg5Sq#9nKZ0 z_6OmlTG^0IFNK*%I(|GQ9|OY6a2`_%&r{RI!0$z)+H04t+tUV#R49n>8N7bn&tQbf3#}#ea5_RXYQ^r z$7haR3J&N9*gTu!bRtG`6J%|=Y7bJV%i8~cG25LuG$F20-0acs%+Hryu4Z*ST?$a` z2rHIzD>lU!K;YqqXtt>+>ymh6MFd5C`hI!!y=VpoPKjO^>bJmu@*AEbhYlV&awYHJ z!Nc#;!&8QTVZNTvD|%Pv6Vb3?+%(6CkfqeiEIRsi3Pzl+r1EUr-j}$hiibg(t;k9* zjJ`-2nyOO~L5`+Bmb76PblFi|2t74gnDS9W=bfnePn=$nSvjBnRkFj(Is>bN z=D#(u@$NSJD=8ivHtT~{qGqNDDA+<)wLIyuu#VCH0n7c2L8I)#uQBoyY$Br5lBo8Ee zf`dsG*bL-ZY}+XI_qB;yTfx|^R7y_4E=W2sk*uWwVg)>$q*7v|8d-&JY=G;s+tTFe zmUU>43u{1dmephzEj5cHL1$ruQrnG5zEbn_%Q||8QIyGDRyW|=d2)cjMhz`ops);3 zb0Gwp%dizq-X?xT+si@C*N}p!c=DHmF%ZT&|j*;);{aCpA< zrXC8nF}&L~ITHV_=}GYO3svml37Uv_3?EFia1d1;{4F08l1JdB4Vc0;Iat_VcpvrM z<^&`&ET$Fb5MQ{C6E^+85^etAsdDqH!Wtfiw;_Vy`ERSq(8(?6&&~)24OB3asTVyOY*9eUu_?v-l+%2rOY@r=}@^U<5=+MUp_VKHur5w zHnO0o0ECM(S+ID+x?m4-Farh1D1%UfF}boNSN1J^OXnL8P0YNXFw1v9NuS@+PCuR$ zAY*Q)(*u)vnE>ctu%3c0IkfW?F`%b4R>a_fd3tmzmS5xE#_JH3GxAVRji+MS!HotR ztCS3iQW*(f_F`Cva4$=;IcSvja;a->eYZS)*eBPkyIa((-#p*h)se`Fp3W(jr_@%=G90@jPW_g)S`WlEu_+XSl-NI3& zK&cO0yHbD-f}nef4Auf;i><2i#wNrChU(5mpF2Y;^^4NpX7td1Zxvw?2hk**EbN=c z@t&t95Z~6?z!Q4TOWssR4(sTwi`=jW`mFTP^=*zm4Ula2QG<2@B?}Vj)-)S|@4C$Fn0}JWKf( zsTU2egv&B>6;IctI5*-W*wE2HWfNPR!H8=Sde5I!hy_HpL>3yZk(O4e|vaX@5L*}9I!1VEy^nd~R@yj!0 z!I5GLZgo%36B^{DFx;cSTK~&W@)8+DTCju$8Uya~BuLi}uz(|F0;QmCM_z<$QWF0< zO7xj9U?kayBgSCuDVDykRQ`CSah>d@K}h^^%Qg68mFDern}E%c1byd0-`!9ov7wHL zxZ@?gF>;`f%7lz48@)e~2;(QQAY@RbW%3iGDIxTmZEH6>X(^D2Ooflcm4a;5sa;10 zfTZ``h~4{>5FLE&)LM4}NseSRg{cW%f2cv?lSQ2v<5b>|LAy{0Zf6x(?NI_nP?dwJ zNzBwBnyg2c?&}}PVsofFS;l(;HM)@7z3auK4f$(-%$7>Yv%CI@>dU^H)h zEtJNt(KxTAESB@tbE=5Z%4|&HFn>>TT)yO8B3F zf^O_ue#YB~CEtJw^K$2cfM&btj+#=?&$;&I{9a8==S^Jp=dBUu^T*u%<^y^TJ@1F2 zE_-MJJP*y;CXrDUq&v^-KKT`bqdy(Z^j5iM-F~SeL z1Ff$?aP!O$J}IMuBT(<>c(Z&$DYXWa9z^rslUUR@jr5_QTkA8`%>IR~e!D(u?<0w` z)$#J&GR6FmhN>?0auCe9qmv?#@6z7}Y6897CN+(vg|mxW4?p0&O^`?syDxiee`4Ujq>6iTnq!~W z|GI&OMx}g;nwJOX$rNW4%IK3Qo*;=$Z6#2=n)x%epcu0<^Zz1s-&8G_0u$vofd72e zlFQaOl05MafOqP48bti44^8|+-j&4eLQX%4Xtumu65SdsMz&r#*Aq1nGHH?{Cv@L5S0v-4V7AM*tws>@4`6N|Hbg(7U)N zYPS>nnAr~}xPtz#@32M1j%W|}2A@{kZ!FO@w{$z*A9tyd0=D4mhAG}+MctsRJ(kq$ zHTo=eF7jPh_~aiveCv&1gyN;tO-4je6ib3c+Xeu4xU9h_G)5jMGSp#JO?~5$)#Dd* z0?(9T8jgAybZ~^5@d8b6tB!C5vxe$B!}%%Bru9~vf!d?;HqC{g+F1H(BH@));U|pq zb&XW~%$6}9UsQ=JB^H;nIwEZvDaFM3|-~A04%)gCUpTG-^*l6++w$an-TY|C$_O#{L(ie0by6Wp&^wfF;Cq*Ygc+q)Vljc!3OF6sM>`M1N${JW_3r$Be97a(NzynCn_|H%>@xJ<3{kSKWr8LygX3waXw{*&!wcc zm5qIKmB#NJut#q6m5+NSMRSRBYp`Lhzxrtvg0)9Wunzp{ByELM4}A4g9!v&1x#ktH zt6$rh6JZGzvp{n7ACFE@R|cbggCl}q zi!5K{>yfyu%BZgXw&PoAlX&1$&ZoF~e*#^bd~-6`wVbN<3rK_H>e-*&xvDz6Q_W`qwC$^x@3*Q${vN-|5g#@*p`ap_phyS`NPr4DToVBZiiD_)v|xgg zas=Jv+mqphGnq7$f=$qzc*+(qib^qd**DhG1{$!+O0Fdfv`N}a4h^)B5EueoImVOU zod=FRlio{aUAZ%pgWs>nY7YL2SJ7 zRsvIAhFak2RzdCAE3D%UnvFi`?3}TnbJV>GTaq-Qv|e_?zLGkb;`n; zQ_WAI%Uiq_WE^selAz38>GQ(JHPoI_=S8n>hU9&42Z_*@Fk}?)`wKS$ET((O^3wN# za;8gvHMSQR^Fkg-5y8KHP)8e(XOa|Py_dgCOzj>^ULyKASgmFe@(tBwLfd_4%9tZ$ zPAjsUqCg1=psdLBzTJ_NqX~ z-$DO5Om+v2qe5-yM-Zq=?Y_2aE375_}61W zBGI0cVl>t??<=_ts-OGJchjgy=O&*ogT60`7>ucvH}iebioYpTZpzKV{OdD{{;cBa ztrm+Fd$DDg@LU5L0-7N)~bDrVJ*Dn`3X zLjns*;x_KKK3`wP8v(X5sF{Ma@?h-vB)QtRsSWmL3fCOHM1}Ox6^YYUYq@WMOcftv z$0o_uzEp!ii)fzH5Uk(VB=$~qm9!I3-n^<}S%PD0h)1LAjfX!micdQXOP|kWu`S#) zS?%@h;30S#{d)mr^?G7DsLzx%UDHOjn~3RRcr03b>$=+!Ad6CbM8sq~z5w<3x5XZh zli6djvA77N3SyxSII628k9H|)m(~U$qf&$18JSUIeA88?VfmAhrWsI;=p-Z+vKx7Z zE+j2|fi{3u(W#Kkr7kX!Spq042&-5zg*gT(%%Sx9;4KvA9#)0A=_1bl5-JyQVQ6E3 zZDqJvbiVDWcrpru?hWHByo;DX7WyL0w>Ck)$WF%%{1PERNN&ahw`_UW^fxgTT>^fx zCHe)xuS9Okl50#_#?xRx0Rf{craJ+X&&|kNrLZ9QKaQ@;>Jdkemp*#+sG9b#w|cQa;JpgL z;HV4$#Ue8|poV;-sQ?DK&a72RZd6Y5(4rAhKBRu*Gn++z+)R16r6%74=j zoDsvhI*XAP=y!LA})Xl(jzSK4)1;@*GN4LGkN zxj@w#H_sMILmjuBAOMYJvk9;P$a9GmC(vwcVb0;ku;$NKXOi|C%<9QwoUNqw%C)zIY#g44u3dhK)at4HkHmZ1x1Ga!RG*49M0|S4t z^Z!SK7G*7Lfz^w&{1s5A7@PLbtTLUBpZ{}zy75|M{$*(6k_V+%yDz;>(sR7!QTFo@ zYruKybFDtXI2uB_Zlkm`F^XC%xQA^u!d(v-W`CLfUqO3xGo!mKwb8aUHwRImWPDZ7 z1_YBUs;@1o+*i4BwS~-AeQ3^_iW&J zxCMCSM-)7vnmv>~8nR_=DYTWtGORc-SL$g}D8Xv37{C_%qNd(xAjT2$#p(< zT=YEa18}!F_b3KeZSl$A4B3SRTcQSrMEC4L!%muEhkVTFUm6lP7SR_kURzbq7DL^& z-UFpoG+We{qT{f2jrK@#tE6tfF+M1QAs8|hzmd&hZ8sr)HIo+}-aXL}3F9JdrIg;2 zHYJntRky>rYB63@1JQ^v=h_x}`=p+siN{Lp^)h`}&4%T-0g;cmU{IgO5Nz2PBC%WB|S8~9TD zUP=}DWElFUdxxzZ4Gdk76YJKKPWBa|hivBpWOROa$-8njf(J{{S0`N_1o!#JVrCW4 z$)CfQ>2lO9!!>nZ^|R$yVbNgLJ0!|_G(Ea{mO8tf1uK~a|HN#^h0WAc!z|{^OnA`6 z7c|s6#@okD-fe5wwY2ws#968yax%py<@X5SI#ug5+MhLztxoO5!!r%b{px<3p>mz> zn>wcic(Uh^vs0Pk?U=Ks|Jy7NEF;J2LZfW+!Z}A5qNzo)mYJhUGuth-YD)Ew_Bn<+ z$<aOjv}m@R`DUwd=h|VWZl-TeR^*dKsrFe;Xy>3K?@{qnrk*GjSW8E+s(Y%zPmwQ^ zGczH@ph`|$uookZz0>WHRI=7Ampa&*O{${$v`N0#kg5as0ion>?L&T3@2gzXLhY^r zHD%RYoXgQ&%q0ZsJ}kX?HrtuIy3bK9n!6Ou1CMh<2*vu#tl`>TuB=|;QnACak*NkH zXNq%qg|%9>*QPJdcT~SoK#s55rDz&)Xvm+bo(>nr&Rtlt~JP$!`n@ z2+5GxAf&LsQQN@_aDYIlBZw?<453KpAQtNc9JBNAkn92kvfKm-tBdeb>}E(gT_WUi zKQen==IMF&C(-Q+h1WcQ)O#LC5OwvNqGQXXABY{Q6nXfgEH~an{&AW#+Xnj zB$bM6$Y{m1c`)~Y5~e)WEM1ia@vCyNC0D_(QuHFOlFUE3LV$&GPy)Vcpiw`^6wMh| zF^UMFs?Is#8hx2)fHfk1MZX?U)bJetA9@uRlt5YINaI^&P}i@@5z;w5;n%}dkan|P z0(z!OLEVwO>%B=8ZSTS-hE$XHONYM1eG2iRPiggx)#ZvO)piORBKn42u2^J&TuMy< zMUH66?xC-1g+?V?Lgq(s-~x#MmmKq-&8ARE0n7n4BpOZhhjl0!M}+HQPz9Ck?Q5UNNcl- z>eL4180c*qP){S;uH5RZNI@MsjU-1*^z_rY$E6J~sBqaDMl0yEDq?ubfU}q8+>7M+ zFFop1pYaTL7j)`X~+=AQH)Vd@h!ImuZandSiJ58 z+12%m(dl(kS)2#9!_e9Zuv6-VjekAzJpV~-g4E=$f!mUB?@rsYr8Qi;rfG4G7Wvw< zfVYlqlSdch$pf1X866Cbbe8>FJnc_cD`S_?II9;Eo3(6$5LJmn?x)x!G$_^Rw83U% zwue)cDOcp+AW^Hm!#i7)z^PU>_1v}yBxX9Ecqe(Un(dpDlTE-n@{@rDdZJ;KE3Kh`BP0cM#`M#^Iy~6>12fv{{+7;`L_c-{YCSFRi=5wrnU~p)d-@~cV zvGEE1XZfC+o|zT+qyV||4znQwz{O83E;(Z6N}9JgII~500mvQgWnMmh0l|VV3rBrR zL{tncE+N@b$NbeU1(k+%{1dn=B0If8epA?Ig@2AxDD*1~RuR{U@A1l={H;o-ey>Vu zgw2+nPXEnifA9AC(I6grmOc9az3`8DVE}`fTiC+{!zFIT7{_faxt$f4+2;-pxWZMg zaUH81VA4K!a}ON!H^V?bbHpJp;s*D!=EWHDfS2GKmk6H4+QE%G51y5^hL?l99pb~6 zUyRQ2&my2aftdvf79tc#m~aumB0)rff`K>N90a6jG0nHoQ&4>q8U|LZIJg$!`#*ws z35ZC@D1E;lp!S|VQ$o^l7PhYYiIefQ-OqE2i0S%B$*oe_IW-L}nmG!i)9_B$%4Br0 z`$*>+x0WYgfkH)!HQ&zzTdqQ-D%EP#s#C8)qbAK-v})6?L#Hm?di3hkZ@{1-!$ypv zCMHa%DH9!bB!^>j$IY6f9!FtnCoQ6mqcHr8v(@u17-6cHT``ywX8E$4Zn^CaJ?g%g z?pqoye^3IwERF;LJjuwuo*!6S&5 zfQW>Qf+`UW9Rm}sF-@GUd6FdK6A%&+lW0|-b4n#6r=X;wripwVLO5bhn4Z`&8L9m7 z6U&w(mzhPZIM>A!7E(AxCV8bwb3fZTU4~3qve7ch_Q+#h_EVE9566)Fixex#NnK04 z%3!ou5rd&g)2Z+rOC`1{)oRpwLHtODHx_4gKeo3Tb2bNOj;4U5P#?d5pb*|;+9KXq zQA~w`@w_DDfBub1!@A)g{v&`SkRT7_gX2Q&R}A*K4_1j2E*Z4!=y2I$#2Nucfo9OU z^H~llk5VXc$M2l;?K`Yu?K|I<@I3$z!37sxa@kSG+;Ur(XoMUjs1Q|243L>{lFi}r z^8M{_bovBM^+>|K04)Wn6 zZIY>>Zf={WK)ygk*n>yn|CnMXK z`e5-%78Y&HZVRntKGvB};oFn8B=-a1UqK2O@HY3$*&=z-M|pMvs6^AF{!}XL?`AIx zOJ{c>TVJpqK3ThH7Zdr=K1Ney-#h!~7)`SJk{Q{gl5XaD>j`_PrhIW}_v!&O2G`{P z4K&)YY$xqto6GmJA%8nUbywu5YrTh?ZOy57C0Rr8cXtWR`tai>-r6Q^BZ0Dhj>G3N zc`#jpBXBE==;u9-W4ej_#cq$M5WOwz2AoK3mN(1a<9s>^RKrsMdyUd^oBZcJ*w)*F zGTrc#`I*Ia&$j)@Ru<=U7vu{g#?L^zu(_9#ZMy_4Z8VTwzIvjI@%BZ0B)qNtPwq`6 zK?WQi=!C9OKk*??qd-udK0aEtX69}&GCO;GPW(Ck^D5nuH27ZXFUOHZp)pe0vpBT{ zm{1zbP#o|Heg((d^Hy%k-=Rv2k72PoSKccdcyLak<;bGY7^8ZF9NVE^kPr2O2Ybg) znnNF;{d$yd^lkXbjQ6b>=pgyyxu)Hp|Gdo5sfpUka=OjhBE?1Nw4;_Z2cA~L`n#8Y z*Ha^L!(#nc9vp7|$>ATKpN74aOZ|$+H0;o{r?O;k{Ud@o$YCiQ8iQqNXn*?=SX-LC z<9^D$9iEHDaJ&U;Ij7fHrWOu09-Oh#alc(9#4qyGwueq_IQmF>E%@sUx0lkCe_Ed% za45F+`y5AZaZCLS^A`;|H|0*}xJ}+RZ*l8?I0IumSopLvp1scxJkGl;`RRWXjdg)a z-_0BAJAM8Ofq(dy7+X2J>a=wGS`Z{e!Qf6hkkBIlIg*t_1+RoyN202@sZFaki*tkk zqEuX)6Uxh&NM-5SRMB^Ur{au3E>MdzE_5oZ13`C8MjdeqIg&(}P-g5S1&DO^M9HcE z^=5KR4|IQSz2C%`({Y>ytLDc|oG?-4K5b_6Yi%w$#FR@N>&OB`m{4Zy+9Pe-wr$&X z9LI4SC(f4Q3Xp0JcLJK@V+we@AFs#d5AEyi>2CG(uc&P=>G9(XJ#p=-OS|)ndCVXo z3Z{3G5n2EVtQ2!&q@%m=WGEVBcJCMe0MsQ|E^g!ch^ib_ zJL;X7MrR?vOC{A3eJ~gtDJl?s;vK^#snV_KtH68h!I>kjyjj?5(@ES6XOk7h;@=Dk zC6|{r^(+;5Q^@(g2T=#^nf>gw3CpYo@K9qB>k4R5WM?oJKO;=Gn36sjWXb>ONllKr z6JwRk=|xH7jY*%^kfV)Ubcy7~VF|3la~!Q^D=l@~tRgKs9E>Je^K|)a++Bjvh^*R= zcpl-?kWiFgTz<(CxzGRk4bw7syWDQoip{*J*(ez#LxyaqZ#%To7wC!1CVq9rC!2}- zv&X;iS%m-z5hj#z;mu86D`WjFRk8vF77P_HASzBE4*>@j00=2TNGBEm@y^;@iU9=% zfB*ylc>sU`c?Adn0D!z~r1qZ%m!MM;X-pyqkdl#8wE3!kkM+IJFx_GZ*vxj6I;in24fN|R5J`e6Kxtd$wDjZx= zSp;riS+bk`AvmyKTJKRQA6laNsTS40fl8OrAOn`xHY1+OtF;CL)#xb|&DU*QunAg& z&=#XiV{dJ-oh5`uud=Y@t_fQ62$DJ2)@pTV$>bU$RkC!sf_zPk$@;Fh3y+q@$V4W3 zlzOJUccxn22hzQF7R@B~F*JUC1TjXk^oqORz(uvUeJh*!c7%Yk5u-`Atm}Itqk%8T zy|FkW!|G8(efVBPq6dAaY@0DA4>~#tkdafQJG``O+t28GZ{P0LrsG#s;QQk+jj$_i z^a{Enku+iZg0m=i?`G<*Cs%0#AK-xjt_i92cZwzMq` zXf;4g`)aY@{Qp~j-Ry3Mr-%Yi`+0+hUk;*1WBG!o%8mDhyO7M{5NZxH2T~iPp_D)W Kf0*w80000xaBn97 literal 0 HcmV?d00001 diff --git a/modules/amezing/fonts/commit-mono.woff2 b/modules/amezing/fonts/commit-mono.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f9ac1ee498c33ac55b804c3dbd6c8035b6828042 GIT binary patch literal 86768 zcmV)VK(D`dPew8T0RR910aEY)5&!@I1WmvI0aAPb0RR9100000000000000000000 z0000Qfl3(U5n@WrxfUKQH?4@VgG=|GXhh#524Xl0B8jMVW2C`o(oSbc zVy+O2UtL!{c!dk)g!z7+v!rx-jct=fv&(soAd-46kj zM);6D5XV^S1My6`6l3;?6ES`60^FELKf8LN`a;!MG`bl@njDs0T4coTbw7Z~fa@_$ zI$Xp|sSB9ST4nK@%6q@f4KiZ!tg|@vi`rp0&Fy%JH+687;(t$`NKNsgf#LD6>#K!yMmF>2FN8cz5JvXIu!!^??oc z0ej)XK;_d=V~`uH#cbH3#RWgg1FMN8ujr#rIcQ?sN>s@iEy)T#bK&b{VFsR zBg4EO)?*QcD-@JlODPPZh{6>LDii&m_MF`FbSYAd@vSFAP)RkijAzA=TG>7iLT@j3 z@GP60125Q*V?)5u+%7M| zXPoD4MCck^aB!`iN(xg(h>PtVCh}4lUS&`0TVY7u0Qscy>)%QF_QHXBzr-31i={0b zlwRnA<(&1!M!SEqdhZ)$zPf6UO|Hn|MXclu2ftcEC}E{#spU7omEOm1yNO9>rlgYASToU4D!I?++y-l*SUM|EM?ak0-bw#hpYe0l?^A{ox5j70c>2iyRB_409-S+xS zzwrMAYx?KDH?z*}A~AH45OIaus!p3Ysfm9xtvjhRL$sVtl0y;_KhZt$J@LK0 zyFHbhMTtY4V8Id~Xdsvr37%plX^;ZNf)z@kNRdK?pbu3j<$*#=fez3*@BUxaGmAGl zW+rNe@gj0Rldp&8MUVF~7bR=K#;cl%F`KCOa^hEFUcvwW-v^*)zq8N3!OXw`4uwJi z49ynG@&a`gsH=blE*E$hfy(W`yIFmN2nm`10fIybK|F{S3RHDe*S)fHV*cgNb=SLM z)*WkyVT$DEYijlIQ;`7ev=bgcIL#8AfNp%+J*MhQ$p8SRC+%(y%92&U)2}_hGka(M zz9iC3QN+|0F$zBvQx;3HX7AfuE#A%Kn`MPLVc=DWj;j9M3M@9HAU#4d=vI}xdof|> z)@3|~hX8>wSYt&s_h0nVR9}BB-io&(V&Ygz_re#Ar%Rd@QW()ca^n5ANvTN*hA_Yx z4A7qna|*qt$+iXag|9$Mdj{)?cPZSg$ybK)U;+0-r0OW zVZ_L}EAr6|^M4yV0O3F5JJK@@I_&G~n_7 z|68-~eQs5?Vwq;30}(N%$J{?Y=b8PQY98q|A#Me$V#*zove_&pG!Ns|014 zIC1bHsR@R?dd+z@W8r%OZ;_Y6V80XL6|EQX`+~$|#l|L8MGym&!5t=^aZ=#K zDb&flDu6pA?1Cfg#RmyT|KPz)10O;UHhR8YOZW(zh$8*n1ZR`QHa!lwgE#4%#ZsIh z8LOojl78Rotgljlaouj%l!C*)n7h?N4Hk^RnpsWudB8x7M=5* zoelteKu+@L{+#yt)GS$UyL+WKb_Zw(2*AO&>vR|bGCTlY=`H{FEw*e`4{xjWReN<+ zb?GY+Yjb9R;hRQhvB#IwytN-)lKgbjQ(fg)XT@L|Z3f1g8PB0<|KaC+v)c|(pJ)4p zpE2h|g*gl4Chyd^6nKP5lfJ%$i%EYwAa%Z_>y_Tx_koiVxoFaKnUqB)yIR{v7y$0E zPNq)_ME5P3G`1cl6%n}~Vw$W%By`OcG&?t0QfP88_)I2C4sIkBe1if&;s5s=(iMM_ zb1poCMGZCuab;~~u##8GLRtj4!PuWx!aw-i?>^vcgl}q$XwXn}6RTkip2@9hSJ;S_ zEzEjeS42VK34f~VNoX8KRxcD2N0FUH$(&!^RDgN+79*shs*E;M=>Qw!eKXmQZ`D_S zYrBi2W1CU1;&H+zqW}te+geutm9E_m-Dar&QSlR8D>|_js@f%jc*MXVM-(t5*gU}- z9Qxnp<9;>B)(w{pr7o`*Ly|5G=6}D&?|kuxJ@B15i0@p?7jKjx*72w%lOpj8$uDMS z*v4G!A-d-OmP1KkoDO;TvIXra-h45B4ppbKcCqni}TOH#Bg$vNrD zH&mk~3nXnnuq9tp!SB-23n@6=lwL}a>X#(_lxJx$`=gYnfApt=5GRAb=6^8EarB4Z z?^D%UQsAuQ6uNt^uW7O1#nxSgH-BaqB(O>hk^?PWNO>2yK+;K8CA+%z%H!d`y_wN> zr{^92QgCkPz%0PzcF2sE`hQKU+%AvXA&YraaL8z506Y^Y9lkL$z8Ec`aH*10Z(yPD zf(3#my>HDv`z!PDkc>JZH~GLqp7FEQP;b+jB~-_xa5~l1nRL?mMD7UWS#`?YSpztb z+9ElVm_WK^E_~S@CxoI%em(CDM%ultUK)%X#k$II0-4o-_r0_i%tmyNY}tJ z4;)J*pcn+1aPEyE^&f+OYTERb_wgu%!T4mcCZ*r))eWT*a_A9EFy@d$ zHi9SJiy036uxa?bQKRmT8L*5+lO;j=wcT<{emTa}9?a;QrnNJZL?Z}@$5I?I_Saa3 zv&5u3={hP?s8Az?ZvP~CIs7$ygQD%t*53Ab7AaCiMTm$H$Rrs;Nc;Doz{aBJ4hf(# zmPSAo1r+r0+TFV!v8(s0WiB)Rx7TIuEQYF}LlISNn!F}2k$)2Zb3@VpieM5$5e7@$ zCb#*&KUaTlvaiYD>jp7EphqcH5J8A#_Tw+n1_YD@M#pUdQB=QC;v697j(!-{Ir}5@ zL-=w00_wEeKy^B6bS_?!hwotJCSqjfZ<2exHTqNQJo|8sI@dYE|0z688+?{NsOR)% zMmb6+u!Z7@63Prz@D;v_5?{k!UIgo2iUan|#lI~NjsLvV@hoNw4wn~kA72(E30wc# z&=Q~}eBD)|AAW~7HTYgkjywg5l&GUjh3ea`BpQA}8epRl6HrqyGjSb+#V)N3q(EmK z+I)YK+73Aldw_U$s9tFcZ}p z*P=hNeq-N;oy6MPBlI8VUyDaO|DgT55r^3`S>Nx6R>K^Jf`a1@~7|ZB^@X%JKl6jI9Vrk_b6>gu*fB##`pBY{d zC0WreH_qx&RM&Dbt$MZIY^JtPIbDv{?Ej(^2bKbLzkOK(Mt4(9XSq+2_%?hmRNl_og+y8O>}~pR}NJo$taiSa%W!CrO$tdHS%t z$tkH#nl@`*+8Bg2jn{$N>P5h5%6qeGV-eHPdS7z1!fu-$_m@u;R)v5RVE zPWMz^K~V_;wI5GtD>hf|JbB~FRB9+DHZDFPF{z~AoySKTc)Yy*D5xt(;M{q8W zPY8s{sv;xx*gzkhZ?|={e`lbekDNMlE+Hx9V@D1hyW#T*XFMFIUx0ABfuRjI`!tNT zX_&p_QZC_AFY%IYXAI9sbVZUI`xR2r>pho4_vd&kcy-gJf`~L{a{N z@@4XC1Erw~d7DS9@$Wpy=N@bP8+>@*{H(+^feeSItkpmHpCW?qWu5es|JX4;+hyq3 z@h5+*{p-mu`N_K$81;>=JX_X)1pz1l?C>Q9C;$TQ0%h-7Bml4-e5hB!fs+j%GuL7Q z{=?{RLc!Yq(f*N_^&9C%8mXFRIfF;7U)zyb@0$W)e%fKK_dLLP)TH~b~wWj zfVAD&2f)+IAP76xqb*?>E`~m*^}qrW%w^i)P2X-4Wzhn|S~tHwu?9$lpB)}R6(aaR z@#ww;&F$dUVEz99qSqe-*pW-y8-64H1up-bs{bc|o-yYb@{bqkYi)C-hMzyLJx{z? zSHr-4of7Txqsd<`vPaE%AirJD`D#O%J%0F=uVLQ=ESzt|9sa)lw20C`nHr-|CXx;A zwjBec2n!thamb`r8|4)}dr?)~ZvF1x{(M&xTvc;1{f}M@SpA1g_PF+C{p~sZ$ok)N zoI?NM2ZSa|QDBOd+05Dam5~}NlwLVUjLw$n&2CyEgk1a2eG8Ahz zY{@=CH4sAB;hX*@nT-MUhX79EtUyMbu8GnZGIX`}ONqS$1yvt~L1WMmNSur*VVSvM zC&BE{>WD;pa6sx~M2t}|#3_5VZbmCKiw1 zG6aUE515>GFJb}1fWA$#pnf^U0tP^g8P7n7=NLPn>E$QUm-?P_j10w2?t?QOWq8*w zpIcys5R8ST$MLZ;DNLAiIv9h<--lZ3({kH65XxL#2kg8JX)C;&Sao`ZDH+1k6pP5) z_w+yvzdPK&A@Mk1=IyWpbBUWRGc5A?zIb3M_6pY_?R zhDf{-2|iO}KXt6*o#G?*jyI{LoVb)qihwhiA)^<&W4(3?L+N#(;ZP1B?82D z*=!4bf%Ul+*81KLHv7>5hn;ZJZ%+B$AI>=IoV$|!ZFiN?)0WVgBb?o z4KRut0TqHlk_Z3e7>5{4LP|zXNe^XUWT#GpCN0`@=+dLlkQoao&Rn3ma^udIAB+&8 z!bOM_CE8;#V#Q04E<=GQUMp0jScy_)>U_|o*+(t9bnDTpPrm^}J{vaXs|oW+e%iKU z&witA?i|Xne7J1m_BdV)76;%7L@JHWlgi|pTH3}oUByIoxO`KJMJ_HqgW`D zxT0)YzAfkZ{(m)cPCw<99pUgbTCZV7fUh}RzF3dpMJtbh$LubZG_xWIf;BSXS->hE z1enbqHp5_!Fyl;{GW_C~y`1GPZ~1$&mwUJNdvKnYtd2TzxOiDGQ*FL#vI10K;cpKI zCGjjmwR()I0p0L}Fp43U+OB<7ccfn~Hu}%{@RMHS6kN?ysl9?b$z+2(fe;cYXik7u zGGkK6|DljeZfl>BR1`P9`%MK9?X z%8dw+dizN=p5Oft%OAGmbAV5D(#QFOEoXIpX*0XqKdzZLFlJ)k^c%m>WiGFXR&6)1 zxVevS?B>QS`TuHsKBJj-dN_TcWvu8IH@(~aWsLD&Oyb<>*1X`QFDKMMmjHVK55Bq5)kgyJ6p+O@Zfz82rv_VH(y7q=+82OkExxj;6#cK^NxW9>FLJiErh zPM8hNuJslB&UT+&CliC)kUMABtC>B!^vn$fd|LDV5+d^g_?yNS=Qs45Yrg93czG8x z=;`%8PDd+{uK2|+c&P^8$cNebK^v`r@5hb1z_$#?g}dO6j&n{PXMe}!Pi!Ci?gFz4 z>uqhc0_<3rQ+=v8H+ogT98c%Z5^BjfNyce&EBQBhg;uAg92R{hr@C@nk~QmE9{AjE za(J8GBAv}q9!?|L1=$9DW7wu9$D-#=v-eh>J62nF7O~%$<`S=6vZ{FRi6$rDgx)0B z6MbPS2bhAeAqFFNt9^x2@c|xyQ@L+j$LH+tsX>;OYn~;fTY=6CO(h5_Ib|wTx`Q@|QDU5!AjD8K0rG=HAV3vJI5Jeh1!9C?AV-W0VFPb`qgHv1 zlED6hvzQz<7vx)c2RwRCcoMG(LwF+6NDSzgOg@}k7CuH*kknX{o)5heMFJuaf+a@8 zg7`oL#?BEJK@k>+R|PO-gb7?W4ujB^dF%kyb|6cKT3_k|4!yp|U^sGxsqp4U<|3Rs ztVSgNJ}T;pSOPt+e(x9~`;Gb8ED@Guqi2?=U-e+McAkAuZsua8dpuc<|TfgS5~^KNn9d zZ)Lx%S>5Yj*G(EEX5;*!o3OBDEx%a6li;J=pzp`mem>GcFJ}+3td;zdW^|{&UG-Hx z>$Cwpk>iHU&9RV~TZrfZ!d9@#6Tq_SiwFr+&zPDVTp$QAJB2sqSUz0T2EX zWqPVyy;hSJtRgGuiRjcWK}ktLT5^!N?yH(&!mhvT^;e@sebK?Twp&POk)T+vQEhb4 z(-+qH#Xs{dJeQTO_imozESGya=Wx+qq}_h-$9rm8@u*=6*zpt~NtS0SG$8n9(VAaQ z5$ZsI8Y3PYu>eI48;`qzYPPs9J5)k@goaj$!t#u1tD|1NwB8Z_j$35tm96iDPkGi0 zpYA!{;{xXX@s5Tzd>Uqj9`b^fEZcLH8nv0SWZmz+K&pli4JN!eVaWq(*?c?=R;wj_ z)z58fudvW2Nr`;p-q%TQU)kWO|Hdym*OjmTZk_6EXL5SybTLJ{?oH9sP7{B;wkTrH zTc8vF@*5roSa*>OzbT zGk#pK<{|a`e0)z z4oDMfQk5&RKs(X;TIf5!dN|+m=CP^`o#<)LdFE$$=!*-;02s|Ul#KGAxWh=0;fXSJ zTEs?f4+SnNn4U0P8d0|uge!kswO75*`n;do(k{W!C|~ATTb6&}M zG#is^8SS$_+{ZdIOHD4m7lTo<`>h5c4+FU=&{NxKHJHB}yCb;msey$#RPY&1r8VuO z{8u#m_Wx@EFr^X0V2;4d_4S>3`G?9AVV`XxE@>!EAc(C^FM7Q(O7@gIOJ&Ds7L zTD41`10emD>U3zNu8>to0oa zK+Pc3uH!YDCGmcb#B=}b5%|UUAL=^?hCXgK2WwuULL*Tl~`EhD~D06KIn4vGiHh zXFIX%6>BFOV)<*s|wrx zyyxF-h!Z$Jm*5nx$d$PPuBil+vC0B2gDd4KxLTfyuaReXf%o$0QKSyRuUy=&& z?G{P3oe!mB3743SqU1`6x3Tj^025CNe(^I4iH&4njHrH-N;)qQZbnN0fLT`?343h% z-D|gObCY@a+ZsX_nZ?3oLg!TE1zK5llK#?l%tcTX6{Nx$iJnbI_A_> zOLQx9%2G3ppy4xkt$l{8{ZO4?*wkwb4udvK+V=q9=>4IE_dP`{wG47jb)E# z#$3JT{Qz-L5}?L!r#gTiQ%nFq>y>JBVEg&+Vqf3#0RE}CG!we=nW0NwVxcv9wsv|} zP+dRlFi6a#21MP-{_MCP?`5<&@q6=H=B=+ZE3*|YezDd3$8-AYf#Y8c0P{c8_pgsz zIiJq`qAC0JQ}R7)GlWNpTl`ExFURySR(zf=uYK>DzqDU7{KD&GD<`|rkcM?-5`2sv z_dG!(-*~o(E@O%yRK}+;p9pgNykO5LZH)8Wrsi?DpFPBtGCRK~^QivTW52tCJ+SEA z9)4f!6MmL>G+d*8=yp&1z_opxLb9b%VUkQFLzux&jV3LRXPEAtnU4l^>-AM%t1}i3 zGUk9Ds+B?hf5LFZn=<@KCWJpdRFhiOMul0@6}H0xXPoexWT&2-PdB#DGnYSGp2gmt zO)h0dm~~t@)1F&-64AWKth~(hJkO#f3cBC~#v|%u7M*zz!wHJUSj1%};yhzL77Hl?=m;Sf4h%ur|3)gSq}Ewi(2vVB)>EZCG5cGOeMF*YNmh z<8w!!cv~*0_%*7hs~9UBJWUtJy7I*hx9a0vx;~!Ue)W&fdne!>B+h{3bjVJR^X!E0 z`&QJaUYptMCZ{>w=}&vAQy=niACEhh&$nk)_uDOxIMP5RVXk3MEU>0|My>uS%(x~`Ay z%l>jk-N%+foRum*F~@v0j!WAbK8}y@5kJyL{wN>yQ9jy7XWn=HgFdEb`}pH^aSz9i z?zxX|Lx=kKKkmo-_@C&5b*M&;Ta6v($Rmy7g+1SkV(Q;Od>NuuhH%x1^)r4gb}>)z zDWCeOp28z5ZUDRW+pFSQ+5g*p*Vp+(x#*@XPi&j{@(q2TzhN`JFW<;FIwzlPX_bGv z(%bf~uIEYb&b#{_e`{}aM?c!+%5fa`Kb#JOtEEBa zfJc#MA`+`bRxWG|8AXSdwlIaECx``#QOJkosDmJP$ZJ64LcCkLbi>4{QvOV82yr=% zp)htNL5Tp7Zv-vsP6U zGJ&TNj+$cuRq=rN-PU6mEhYF+ZNG0fwq3KUFno_6E-()K&lxh`p!8zvcxtR)0T?2XHbfD&ymN z`i-@O;XSd8Wd3xf4(_B$7)_Bj0SYQv@;+nCJb@>1RG0a&;8EY$gLc~W;rC=b8wUPR zmEi}6*zLoEKi#i)ZJFBq>2?Ma;_ei>%G_DCk;IQ>KECv%O@X7h<|J&Gn5ZaYVeT89Ew}>|qv7znniJ!E-2*j89RPmV4BQ@}yuUDw2|IP#q~d z6jeyoxu~8rxEeK*CXb+TWW1lC31p)GqDiFLhp2_t`dA%mBkg)o2dPiR#xtypRO8PzlW=~$dkwL}h& z%jwjEV3C&;HedM_$vu`=pZj(Z7IrdFlq5)J#Ere!ZO z&A6fn>nekZXF9EL%vI^Q`?Se-q24=)_RK!8Iuy?QuL}e zG!w-UG3}4hgJ48jZ8o&@5eKc}N7I(P_{n*{L@uT_b^|?d;?qGof_A zoIiY+_D@CKibqtDafV&I!h3Q;V8$ByRf+e9EJsQ2eB z!Bs@zJlt9l&}10II!hAOgZ9BxG=9^0nlFt|Go&x#HfOc;gG&PVaJ4+`f2JtrtUjCe zTMmv7QmHjth>g(tuR+c<6>U1nn3t3VE~5>}dy&AV->y$1VH(}y#5_CJFu@sWqpaY( zI7Efg6rZX&ceQF`n#BS#d12>TX@nsCrDr(zCKHMgBAX)rgK^Pwu`tnHU^Fio9J zHi@m*8KdHi0VR47p$(}+>y*YzMji@}+xlg}d~En@V%}R$-BHdY`WZ?eqk`;ujW|~# z_EJ|`WS*LJWIzW>y~P2$^jaHzh%t7CE0gGuc8+sDP6ewvLTQ;}lkTWXCUUCSmYu>6 zSM>c5iT|VJh_S`Uy#phSuo_X$Zu6EGO4lA8wJi!S@)^8O(|8N-66s(ylrB|xfNAni zqKk3Fiapjz?qHHhMOTx%Fr`2G&e8LgJK^_qOWvO@P8pfJ44QLR0iF1o=Mf>&!8J0k zaqa$9DVtg$toS;WTqhw&qGV__Q^bbrbYfTuyh)Wt2O%h0IpOV2z0i zik(+q4fnS0hgy=+%(vwvTMe~3KrvvV6(OnPcoowN09G021R<)!Vly%b^QlO>vl-_? z!0HB3*?cBRy5lw>Sc`TGj^)#Fl5|cLiNsw2*FE1^LqhC3T(V{R{=SM8_7Of9iZoGX zoaLGr$5EFZeNWrF4;||s;peAovvlLoN=WCzzSX*`HmiIOPAQgg>?gtssn)y5n|p-FHSkvitL{1c6s znvWaJY|`Xz{zc7YXq)ugx;5;!J8;@!b)EGdJ+=iP%@We2!74pA4uCn>J~XVI-+UkX z_fWEKGN+^_n1*YsxKFGZV?Q*Y9Eaj?Pf}#f2>D4(=~o7HKSmDt$}og7h_&u=Ql2Ab zF1@B%p}Q*m90LRq3UE+`NNyBbme>hv&r<%xS&_^S>#4b$+Y}71PghI}F#%*>MQOo9NQ~r=Ml8I&NMov}1*A3{VI=No*x=NLp zKy)KIJU&TZNgAbuXL(a-e;HkH-uDKQJSA#hZ1P%FU6(=_W|~#HL-oR{3IQI=V|&a3 z!=bT6zR)5`sSPzw_jS=Fy9B4#VX-S+l+^8^Usi7odh`xqlIp_DOPWa!knmb0La8oHX;iTyy`!ve1HXS9kSm`AE^Bc6x)paY z;6^bs_j~#W_rf&E>JyBh0r96-Ls51o%K0QozzDuh8zpxf+QcOdQ~z<+$(mcrUwX-x z<^$_nHXA#l;kPcDO}1EkucBBlB&cU|dkw?T-kkYSY*Jui&V88+fSkP%W0Eh$ecM*Knk1zH956T^rXy0Ny()O!%qa_ zN_$!CRC_vyv?qzU3M_r0?pjP5YfmVm!JYQOwNa`0%0m<>AwM}C7o|P!TYyHXe%1`6 z;wtXOot+Y*4mbo7N|Mmu1_mfnPkd>m`6o*5a4L;@=Cykqe{cB8+Cfq%*yRCOj=9S- zXHJ+cGR#M1%DaDFA8D3cvS^P`kGN%HSCV3>Tw9zNMVe)#h$T9rRq7=joJAsvz?!gG zhUvGJ#m53&MTn1XltW(?6gs@4LZtV@8)d#UnK+PE33YmVs=LfwW3NxHGuQGsSEpN- zc+01ss7|`YldTh2?1vAw%8Ie#1U1XPCrhauo5IXntpn3&ZzX$dnTDB@-4KnMD|P0q z6z3IqnWG9X=Dt{(yROTSI=@YUz_w-Et^X*@VX&Ln93FqoQaKie<{bqrRDjq474wnY|H* zzr^C7(a%=?a|a*%S}KgIOak_WKV=4OQ1pJ8Hv zq|8tB{LCo9-Fe6shsuYnc&pXk|IT|B@Z=X5Wy1Z+&=>m?Yi`>og+Z%X+F&(RgqDyd zwaxA+tx;PPbwMsQUn%rqQce(WOyD9EmZ%Rc)$k~dvg$Q8->z6r$mRIg!MWdzHWQoh zBA(IwHKkECS$1(3FSi|fV9m)Uq&DhCn5qWQ1-|x4rfxHR=gy^AZ9^1aC!yC4z zt8-?kXWZvKiAs#rw$#Jat42Def=ix!B4yb6|dKdWT@iwcCOW^-&Qt`EYF;9qfTz4QWZ+pQLp^Gx^jdO$2i-Z zH5bP_$L`;Cz*oC5+`}N2K&<(!=MUQP64U9dkPw20RT5w7b1>=@jtyW&ICMpr%dd)N z^2Ja2xHn=IX_5m35pP9j&z)nUh4EQ9Lvfp`Pyj{n| zae$+MV1J;Jib41X1p?F+GYws!uBzU|GSmWTi>AvL^u4gGF+^FR5`ZUduXePNf zmTmwlG~JdfDsfv}8=-;aQb5oleIBHNl;So3Z6wjaaw#CFNuLKPkz#I;pilobwB~n> zdXOrN0=LDrkwgQ_rGTI&eI9^>p4&i3u>=XA4yiP{21pL4fFQ-`^^yRpNu{w()(Rza zb6bEiJWsz#!<9bL1`6(Nacv~gz;Y=ds7ap(Afe|r5K=5b0;oeOjjjPwg;PKS#idw+ z1W-*XjjjaAVU(as^DX8^m7er`0F0g+qqxI4>9wPpR2mJ5^WhYM=XAnp7HH36jGoL6znss7#8>;VK^hqvtkIT#6+~0M(?@ zXh@t7rwAm+xd7vG4VZcqF3qMn|@)n6aWzv4*y`eJpa?9x;S+RWP zN*CEIslp`+k*nElo&BkNBaoYeDJg)O&pLRRuICl2$b0&tGxzTpuDMT;S()NC?;{7p z&7`x&YrvV9?fm2#Elwkee+MGOcQU_FJx;TZ2oti*K6%ydBD&=Fr%!jFI*5<6yF^ulOq`Mwd9w%)KBZ9m5$s@>5H>K0A`k;rkf|!5 z5Aq`gwSv+*+Bn*TU#d9d^=C>8C+)g%M@U#Fv?}4o#ZEmcz1yUJcoxv$f)!~W+v`S6 zSLW0p4xN3?m5Wc`J;RQ~Ebz*A)Y|J5EvBYS-u(L7r`I{H>3Gk`DKI~w&N@!n*T)No z)5ZNN(~i>*j50;`Y+k0Err_-hWn3aq+(I+>+ZnE&F~iqxTvt2mEQt;tq|zB#S;=@C zu}a!?&A{92OFHT(+7I-k*7e(LqsxphK5rWia#)k`!Xt;?84K**T%fiaCMxFi%t0eEUM)NK>C*^tU z4HWWEnqfFmiWvW~9xaN&Wxr)0rf9(G3sfaL!+qZ943PI_wL&6Q0$XmU5e=yz6)`S) zp6&=n?9xoO^AXg$(qv5Kl=skX+9g+asGAh=)UP;49X0UMk%^0;di2I3yH(TMa)2>=*M=hQ~l@YX3B~#~F1XP-$x`DE*CKxl=jXOmh zxy~FWCgWI2F+k2GWey97!s?ZaHrmD)?`}NKy0J{*hcrgMwBz{?mqRk>$_god8o1i) z!!GT%H6)3ta?<4}MmQ*l+W!;SgrP5&?@d;wu6Yn)1K|8P?AsT|Pk625vER*mcrWh? zsLM%>R0Fhy!Z5*rdNTg54rjsle;hgM#kn_rB_4XMqEgJT@;=Z%_4xII?V3f=lU;;y zj98i}O21Y~zmhG!j#YO^ppXceneO|OIz?19b8G}yg>+f;Qut<;RIz(_!Na!cKA-=+ zK9TJb=>ud}AG|U0o}oy&XXrCQVS&2%$f1$$W7&9|=^~oL%auHckPXZyBf-PwM%zZY zQRwD0C}YX)tz`VN&KA=iEQd|xss{#DhZmM&Fhy|f5v%Vfq?i&;vONc5SFwLnw7!a_ z#WxH-qm*y0$DZMc>xv0VFMYcC?Iq{%M zK?Lj{m&UI^YCNad=j`L2BME`G6gkpkgVyCV2%mYq!&=M zi+$N&!7Mn@FI1%;?+>4PQ_^G)Pw`D7IqrN_ocB?+!&i6yT6|x=h+*}QvlInSWuQQw z%aj(5;so6PVQEZ@A*UJr4So>v>0<-&d>{k|2%I~n!l*nzQ}wA`^m zF(S?dkwVVUYrX4Qso5$kwx?I12NPv@gb)(t@d$0Sq`b1y1M7`weA`>;Jc+%Vs+ONu z?F8%cLY+~Ua8H&jf-*M%tSlNi-BC#P5xYA@b}g zDka?xsU?#Ml-4=L<9+==5mL-`sq)0YEXZa>8n=3JTaN+f)0|l+ZH=D-GQZq)m>)Wc zvQo6?_atQm!7Dj>j;~q%SzMf~4vDN54Y-(R<9uC&bd0NE1Pk2I%r}wnRvkh)s=CpT zux*2t(44gVSdXFw12M)}c?z6e>()h6cpr_%L6Bgi8n4c|ZtH^69R+BZDueozZt9ro z#lXFXHv<{V<;_DHS9)V%Le4G0$}q!g?3mY>`UZ1edMm5S=D4mj+z%3>{OqNwQB#RN z!Nn=Ba1?FPvCY-mf-H{x0>q?aH4+FV>hgE739G%$P&ankiFBmP?Z|qpqDV=*7pTn^ zXw(|lo$`O?zHp~lf&pmP-F~_5&@x}6xIGA?!K$Ue%C|ZuID8wI|9Gm2S5N%L7h_H} zif2ZI0Fp#cdJC>?=9>46JTf<2c}ur`^0|_ll5C*moLLalF!7S9vfILmD76pzWC^5mcy|de!1A^FC_KPGWjw+dx7Np8axT898J5p+0}Nq*Rh|Cn6Ze7o zr7U?}#hIGS;;e_~sAo$EM@7#VD95F2yX;W5TSe$iXFa+}u}RP$@;_n( zq~j>Ya!@ea1Oa0`@5b8K)eFkq9%~BHUv-@IUT{i6uA7V}c7UDv&aJb%xEI>A$98w? z80I)8@+V&eHkR^D=Y;+CbDzBXS+x7SR;+)eTsa#f1)Mo+Dp&}!h8Pzp4qU}gOxJkg z7ed|0$uu@YW#|C{Y9lXMh-5ze`giqri_9bkvyHwlgs;(P0&~~a(FsJnmR(k}&A{5a zS{sPsx6L%bV<||s3SC}i8Uh2$a#_SBH zks=!YnP^9Vni97x)+BmgcS5+ypoCjnEn>HD>v!EzLjM;r)j=PcD>z(NZQCBx-dz|w z%@kwGJBW`{RftT4Z?oh(D*ru0gIR3_2<`dfUjFB+ykyW^RI&)bBl)G4QqVaIbL23GCDhbshH^^seB zit?RFvy$VtuLy0Gg9%PV44X-PcNlWbtgpIvoo(9kGO}Rvc5GWK9_9cSW#Y(0tqZiE ztQW5QU5Kz$P0#Cj+szx1s77eWuZn5`GyP6ibyYY+LQ`ppr*Zt9Pp99v=;)Y%Si0?| zXbd-|9K{Cfw%>v?ju4&>?G1Oq-NS{A`o^z$7yOIeTZ`mKOGcCIyQ1=|#5w(fYp41M zT*T{l?_%xbk1XJ?W4!WTOHCekdNKYR4SqXeJ7|Br!M=xaEEl`wSr#$Iii}CS&8RHf zY0IVzgJO-9@y{*FyNlP%?9t}jq<&PhN7pKDci80oKdx%xZu4_#+7(PK3x zjL6xqRO3i);HEbYfilNO;I$H+`r3f>SZfRK0tQXoCEqXfrZL=$caVDk`YERi%P66b zcj{Q(=`GDU_eOm1C2WcvkDV2>8Caznl1T#t_${n?H=`-dT%7F5=kuk zdU57tPb@#|*~#&wInt-o9VrNYo{>J$K~PUl?d|q_>>o64cIM@ zlO;uJKM^D1)&ntD?xGV9yfuH#;58aCKp38~I^VkDBQ_z7vYiiGV8yOrJ|5FR1#8}6 zyI!~3G6XV_E&T%4N{E6rD>eq9lnE))hFgRRRI&G{iojBe;3vdatnWvB?RPw zI6;v~783WVAol;t7P$m*6zsVDx`+$Y5%wY^Ub72LJK;i-AE8mrHV0;6l*%6Ch6yWx zAs2>8QTXHY(|9pe+Re%3Pfdrb&CT)G;H%!0=C7F`7V_zngu|MawFI&_4yvqAL{YIR zqft%vt`h^&vdjWl1uhtAmIdhn$J`bmy-i3*``S%UI~hDbDpgC7sQIHRFrbPLFMjN{ zMABC^z3Yt9LirIVHlm!2-$Wstc4mRuI{w%o1{kN>w9}`g-s8$E#6Rqr1_)COTX4|v zJi-nmVQ5m)nv#)HlKNz38`!GM3ZKKl%mi)i9ud!-d<$(biJz@hq+5tZC{>~gktjB8 zWpVWEvDYkZ8Hr^ot#1m5I+bl{36Us1-4rg`gAuc%7{mXn{7ndnK7Mc^*aP*&<-8KHQ`R- z+VBH6kl870B#VrUGAwfk!D&Xe4`*=!mC8_zQtFH1(K<|S4$BX_2(uu9D4Nk$$^amS z7-G{08{hE2ouMDJl_UngNgV(XE%nn_SOD;lV^PmA@C;fgOB9!RFp@0-bhyDz=iz6x zLJ;*cHbVz%pBHB#!b6e8MiCaQzQf!c4%b)j4Ge6>8y^Ntq#8J6m|K<*la;-t#n_we z$}rP-JsF+)U@yGW^IQY6vKe)Gk77ZM2UCC{GPXQ{ol2MRVYM-RGc4JQ%3eQZtv@K= zD578F+zSS)_KHwMh^?s_1PyzuULl}rTr5>A5pWd;1I8uOy}wMSf8PAt6YMdvzEJJ9 zB)6ClWTt=b?Fn)Q6&x6+)H7!EP2!XlX8V_t z-~kI){}%;A$0!^>*A>g0ZC!(A$6Bd!pQC-Sx-3?XeA*r3hPYN4@)|1Qv+@AtSmD<~ z*a@`gcaew6@bxO_hJq!gQ5Kobz#c~WVAa13^jT=?bsNhaJyeuZUc(EO6AM1L!4u3`94RJGezY%U&6 zfqPg>9cgQ2>2evwaNayqfLly<-an{srkXuO7%@%*mW%)>HMDDlZm;ko`^q4emx%BH zUQv77##DChK;{3x;CJH2N_MNN!Pu7GqAnklexuu8+gobhox3s8@oLnK_xOnz6`8Gm zErYpCEJbig(^jf1>N2;{?<4@MlO^i7PIJV&R&VE79tf4Wh$c*rBf?SF^MWvh00%V zC|cg2$^t&AEgn!2pU-F0AL3F4n#wiH*05q9=n!jTC(cSBo;1JfATh{^(;@6zm-+Ty zuJ&|UJMNf8Rjj-sC{b9(w6zYcPJ%%aoX^DV<}=~TV1G0rC)PppB~yvIck|%m&eu}>bohinmL`z)>&Sjl*>Fr{(L#^#`4nBcS-rbkZj&q&?hLji#0Rb}th1aj64&NY0S{0AqT}lYlco9#2dud9C{fYl)Jvo9%b9(zGz_8kO_^rpJ@SL4&7UOpMv(2j-p53_sje8cT68W zU<`QpkHkw;(cNOZvuru;rCdxepxGEMiuiZ4_Wc3GYBv~cKHdH>>9oNUvRXYs1Nf=? zP_YY>`C)CCHrnl`kJUu{61gGIvelGd(dH$Kja3>~1%FO7%5V2AR%wl}pSo9QF9cR+ zEp=*tsc2?6+)>cb%yQP;9-6C)h|v(#bgH_kpTav_GlR^>Kw4ts zFRjsezpVM5tW#mQ#Vtc+gUb8KN2=Xxq*N(fc7X_XOH^U%7^jCZRu>7v3R8h)KbAjw zqn|9shG{*Of;q7;uiaKYRI5{lC1@BL<=|`|+-4m01Vmfr2wKu(i9{oo;}%_1XIXU! zqryaaw7mYE)>*oe4(b-8pM-Xx^U)olPe4JIrN@$(HIQXr7NpALxwP3#mpFM4`t?;P zHf}t}k?#@!YxkvZw*qCIk0pJUKdqBWGUW`shDDyrP&?aGjnvtE=-Y_i*l z()n8tBuii~tPL{iF)guD61^KY-$EDSbf{ezCzNYE!pWYY6&eHqg%UK)k;Z8Jmx*<_ zh3GAF{4N|NRcbLty*B8DLANH(Yt36WZFws|Ta4#mH=RTCz_4hX#SKm}`?D_^Tmg#( z?gk)<7%GJ>4}Um@Rn*fklYuOrC-XsiT3D%6ntqusBiz#F9k(0@9?P8Q9N*+J5bU!& zmr^~6ZW;_LEm2w0=euG(uhecY<>^tPKF2N9-@e5DMO0(b(CHfd@7^i?cvK{|H5Gt*RJXjTJ*!g|=Y>ft5-*T)5B(<2Vd936AxUscZtWWQmh+8zsw0IEC zJrM;N-kU}p;{U7ZQN(+ebt?bkDA2x)URDxEpd*y;R54((2wtvsU(Sc-^=<%rfUcPn z)}2GA+Mn1#Z3pw}jdNpCMn(g?MHheJOMNUOJ*L)7grLpC&^?38+~;^b$xb3Ha^kg* z7$#3ZsW&>%an1atCysG+?=?t7UzD<@ra%Plq}k|oT3EiJpUZ%uVDsuetLq2ccdQzSSfcN1^ z5i%#uV9}?LkB%R*jY1c@@vfH)lP9D!Kp$vCN=E&k#Sd+nS^vk?#>uU__|@MVmyw;J z9a&q|e|@6v{Uez|(=U0kJ?Z~of~}?iD2`>+OlH(58p>_;<%LTXn-b-l4!-ta6p`F- zwaWY=66%&0U5BA$MYdfe%CRewA?ag3d_qy77%dvT6Y01(W2Is3Zb03ZTDhU$2`+!D zCr}LVT}q14c@MewN|pk8 z_4!!7YU$Ft@1fQCJkb2YJ8o0Azjwne42O21iR#9n#_>1ucg~6x6jU7pURjPrPW(h* zE>FZ>83oT(%a8g5!JVZ#*@BZnbIu%ey*>fTF7RtFzYq*^T^{Zfk2;R_o)Co!fCn?`^UBe@PkZ z7SM*yh;uZKG5Yv$`P^`>Y-X%{tk!9$;^$?pk3mYbT|}C3C@BtB@!;KOo$FH zO~-3{25>g7Aq6C1U#W$A?;OP|7F3Q7>?1n1exO0TU)PYOs1JqkZ#n{*191b(K;3WD zfjY{2&qkQ>2O%q{@QJ7RhE}UhmL@b(E$he)N@j-28?_cwjhsr|8s;l?C31V5H`eLm zPp}saRcTfEGINwO%H|Qpx%f1A7W+xB5zmEvCeH8cQ9M`TIUh4uXrX8;F(egcJHRlF zkr}{;fp)SoA~y70Su4F>=L_}BZ$akW}p`mXbNr#~gdHJa*ve0q8StRFQvXEFKI zU>F0gWNAoi?_Be7B6nvD!vZzzR;2l3pUT+<|ycLm(2fU-j;>yy`e#0NJ7nB3wCYUH?&N9 zxQ{ioWG%?(Mo**bqaYny%2VO3E&l}u$=g_$#hY{ru=zr{H<)iVxqTK}Fk;~&7`Jmn z8;>_)!&)x8V}4_~z&S%f%O!dti7doyV1^ZPC{!PWWNMLv@_}L(iD`}m7ZsNJm?%A} zevEk`Tk(dyUN^P?E)<7~fz6`r#oG^SA7~F>#dv$QX?*;CC5c~(!tP53Ok@6ba{5FN zu9-OfkD*X1wpu-9+Se{%wQ^$d8Eo`!cJ$138#~5a~!dyD+~bAAIjVeUk9& zO7x1r+N(5;0nX59XTWuumea^!GWx70Tg6&o6_1OP z?@_7u$TQB!*Zb5(z1pwee#glsN~IHJ(*f0bZ4Nj*l1N^9aR8Bs`(@qi^jwrM@tYbX zmHduGdO{}s0(e{0sT4|Y)&;*@Ww|!n78`$MTUXom2u0Iy7ks}1eD$tLx68>x90xVy z6H{0(NWTm?DJ*R?pId1D_|-WyD#`cqu#^>?A7V)YWdOXIVx2QIYzcsEL)zM`$!W9# z+Vk`!L;~!OWq}FJ@m|iPH)gHPdHrdVv(^~TXhHW6Hj|xr*)x{K1fk2C*%M!Ez*Y~c z1pa|Z*pZpPzg~{X+J6K&@iKOOAq0LTrDR9(#kV0EUij5y&$W}Uoc1i(yi9gdL3(qdiYVKp zB){p6YB#%M)LY``vInZjip2_{QmmtW?>{Vis|*6ZaVUfdj$#munzk$u7a^@kFVjhFTHV;=3XxLC*IeOkq*@ruAu`RunX z3bF^#C{!z%ltyJpSWukK7{_9a9w-yrbSjPAC}+`sOqjfbD&8iSZxxpF>wlbfUwmN! z`T0k`PGiA46r^c~L82Tlp;93Vi8(I04FXGUlC#N_L^1_X$eHKKM`^IKYXry6H%@mN zm{5-(N7gUvjbibUL6!qJv#QSwB=_{#LP!AzVRQvL`2MJ4ezEIz_>*xN z+c$VpQ}H1vDRBo?zK~*-P!WutZ1GYhogFLsi##ATAvcg~AAN1+eXW)t_Ym;Mb*hH? zoCL48nJK&f@Wm|G_Wb%#yW?OFn2h~;Qu`KUR6S06A<2c<4CMYMwMoK=<&^@M0h$k? zNr=1M=3BUD>KS$`yxV&(|7~p^_vu4 zJb3!aBWIw@zgOWx{iYrVuiqqnC!Y%aiau9=Mf85uGy9-&S1p`u6W#o^El9-SqxAG+R*h2pTw;fgy?LK<(R)+MXW zGcj-&+<(n77YdhV==A=g+~2c(`Rx~KHuW%gy(Z}!l%M`EBcJYr`Ek3kJK+Wvhfgzf z1Z&d&r7Rc3G5(U3v&-R%J5Mq+E4sb<60yyF3>ZdjF6OSma~ePWaq{YQ^qa9A>`u6i zB2429pV90eF5Y4+YA6>M2Tac%iN)5A=7l+WjB>DWC^#q^q+`3qu~i@Y^p{zys2lLL z0nvZ7S!vRzf5D>fNI9oPsNdApsQkQslVZhw`qPYTx)0{Z)m44$a=3xT;nU=gcuktV zz|jlhxW;;Rjxnwgms(>zyS_wfb2$Tt!OdL`=jox-`006t-B-3+kl&2$V0Xf87wc(` zF`~NgD-Zu66ldX3>Z=Oo<{h8^AV|-yhs7Z-6oZ9B;X&ClT_3@!^|epG%vwcl(`yZ} zLDQ3`G1Gt9$`Er5WJlx$>HqOLGsp^Ax2K2|Me;WU^7g<=iw5l=JS@V!^26xmsK{>Qk5?2S(tlsqt@=y%-sy~n~S@_$16jj14kjSq@#Lx zCLZ1aBsj|6w*&$NT}&LYlPWwYlN}UNcaBKB2tfqG+r4DdAMxhBCzIckNbUGlj5w7V zXWY?STT$#Wl6P(INQ~r>yvJ(H5Z9~^$Xj`o)y)peWTJI1mi+CmA?NU?3pv~cpC0C1 z@B7CS=CeQD#m|TJih7g)z*ye?a?I;5G=Ppe5*Z2P%hmiMo2{6q;asb@^f--uT)bRF zcP2XNLZN7h%XATuS8)+kU+$rxNMz*5DO0GQvFGHD!9D5& zMA8a}dxoe4UCbKr$y7lHUW6Slm^?Y27AoVSy6Nph(VpZ_g{5kd;goo`mk zf~YmB4kI?TSmH$th*JTE_H9SD&zH_X)pr~@KEE?q^r#c*oZlI2NhzBNkeNPgQXrD@ zJJ=lKUuuXgzs~LR)^##zbNMYnWlD!aEg{f_4oQ%?C^xr!`(dq#!R~5K;}=Pgu}(X|Czd&*k1{`QEDl4%~ygs8Sd4zoZ67iC8 z_GNNy(v${~28;-u>FhsGm;OY(u-oL}+V=*jQdNxA$BwC#%-EtZTMoOa>y)%4K2j{2 zMsoTDGX9TOmh$;aj{m^36uQ9A5*2(?LGAjGLQ$QPXZ_Zbm{PY;`Z@mO57STQFbY|$ zLIy=r;5l+et=+0onQXG`-+MAsY8T2rJ>_aP^k$TTMafwC-V5{A6Z}Oaw4ACtNNv10 z+O@=zo;{+gy^DQjh{ItwkT02LN&bzdtIINm?h|L*&wkU`TKkK{s!Kugvf8*Nxteu2 zPvGXBz+Ph(rRYuJ+^Fw#XoppN?502S)zkB9r9z9E+pSS^c zkra{UePOjr_0qI*{YG%)Dz!xY4JNin3<}DmAZ}T@m^I)~oS@PblK++REfv1lT=G*A zR4c=ToUdl%K&#uF6 zzS2bCEIjss{P5o)V-3g?FYN}x5PaZf>T4|rKP^=pg02RCY#*AAe= zp0)#AeOdoIO0}UW$dp%d9f%}8c@jdsYtBq$CfFUj-{{+6erP_p0i}dFj;l{hQ?BB= zJ2qVnUPG^epBoopGk!G%ek;#CZb-e6&6fr&Rz*NeLfwk`>o7#I<~YQ{EZcuBRc^?& z;`4DD_~c|ZzcJ7L)U2;Q`*@+g`K;mW`!0acHJ{Uy$iX$h-yF5}@#MxD5eM(P%@;YC z|GZ08%(lS{?ADG=peCt}gXbQr_1xWeZ*#e~@9yTle^Ai0Oy0G$OHrX%wRDv{!wEG< z0KIEFo7)fmw6o|kfRhs1$$Oq%!6<4Q(gtAE;mGjd;eDbbF#%R(v)|CJNduP0l5@xu z42ubr3FQuP8OwIqbdEev^06Um9%SVBJ#IpZ3U2>PPap> z_M=DJyUf_ey1G4(w!E&ppKSm20oF$^9-c1FUpdryETkzIX)(T4up{duY=u0^UcXq1 zx4SQ;ys5``ak!nwr}DUvko}15#XDiTl$6s&z~yjlMeOSG)-(UDEKp}6GjnwLSWrdTU-YZ} zM0zsiHYgZXa_p(v`+_ti1@o5i1l6~9^8dBb&slgYkM(HCHW~HAuUUA`6Fy}1;=^(i zqln{YIQRyi8>54YMzSgCgQ1%uhCMVJ-651f4z3p}w0Oh?oD5}pHj&Q5y$zHywbfC^ zH!p6|V7lIBO(6}?dlxu)0TwMmER*C$4HE?30owSWdGacswj5#M{>rD)pJZowqzfcp zNn3o!_d+{Q7+*dBpxXZ+0rP~=cNjBWZwM-Xt9eZ#n3Rd??)lx8P zL%ue@1D#(vz{}sHL+mK;VtDxX{pE=`3GnD@QF`6Ff^~{&(n&fM)LJjE_3OAh``CN0 z)45lr73MjzM7gBG)kaP<53Db@HIyqlw#zPDD&Le?f1}RsTD#jYV`axhWb{taXhnqr zNdNf8zfPp@Qs_2`FN3i=Wd&S6b%HYHg=PAPd54|{h1_J3v0Q7f;?B#9v1i#zN|Xj= zl-&P~T|LI-EZ8`jFPOf&o6GI&odz<*d#5v`A@)L6M6Te( zF2y)XWt7v)&Qs|n6HoTMa$gbDHgJyiF5&Uoj_X8s@8ad>pe4QCVDxLbFv~6p35b8Y zmA+Y0v!}KoZZE;KcAG|ZNEx)i>v+wO*;}c=udU;EZmolJ+XUU8Z;EbY$$oT3F4K+srArn@#gnfS z*o)HgH5wedh)tMX9ZtV!)cEH_ka%9+d~E znEfNx&zKp~rsi*Ck+}Szs+8|?QKlUSIcF=}z5CkT3#6_oZr@g>o z5Au13;nYmau{Kv9IQEmGNTDh-=!*4!51rrkGui&jfaLO@Wko6xuP4fDlJ`ucav+G9qNgvH zKHWZ8J{ashW9aC^XsPtW;g$uPT7bzP#rY@6!5>YUU3N_4(bp*7Lf1O&*6I zcky@ni#f9gj{+>`u}EtKoAX=|?vso4c5qZ}nVRB0>bw1o;Ki=x$I5jVr& z*9XKjL#fQ3&zT%#7L@JhFWqyL%RRP-WD^QVw$1A}+)Jk^b|7jy*=LJL7(^81a8Q)U zco~0&->Fb^@*j@m-Q_O_!S4ouOoh<&SC==pjTpuR@czK_-HZWR2Lw5^0~mC|OCDiA zcfUX|G4~hVEe9~#5bU=Pn)Fp%+Fv|11gUv{(YUM9KP;Y>7NY*2nLChK18SL{YNb12 zSTt+zCTq6nKd5a!2ZkYQJNn>YH2DWIclwPSUDDY!7;oP(Pdl&m*r*49wp@S--24z% zq!^ZmQL9y6w*VG7IO)_PolDw$yv43x5VLVd*g`{L1TB$S^SG1zVq`%G z({*+HB(I~=ZIRl^ok2vy3)Wp`=E6pNHTaOYysjW_BDI$<4=FGv#X#C>Y&mA|)jlv{ zDIc*UA_@>~V)*5t5Czti30*E>nGU9e##*A$ zha$2VT{H!L8wPkv!#*WWMyCy%Qxqv_D3xMT1FO_zE8yyyXKh|>Bu@LQ8|%eDCf`(a z>>F60ZZ`SiU@`U>3`qr2AUXe_SB#_!#o<|h_Lm`A2j*NS0Q~kj|7}akNvx=Rr3?PE zx9I$rzwu7(M}(afKa{QF@xL*9DKPA(ls)5_$RjG)bd%4ef!e&g>?Aw;n><_1_qvh~IdHG z9Ps?FW1Mc&?J3JPV_;{z!|R2aCu(~sK$fvW4S$|IP%#z~_NAi>xZKVYy6Bm7r%)(b z<}zLQ?(G=GZdupDz8`DeWs#;oXSSiCN$e8-adjQ<8%#=9(r8M05DeQX`~Q<95lf&I z&_X$u8BUl3rdT_{(#3D`=Y18cVnU;rHQe+76Aw=AEo^ud=6GP&3e;>_(>?b5Nc`)v z1CdyH`7hhcgwuJ{L>euT`hdqP5<2hov!J9x4?zTb$db!EEbbYaQGI);KyF77wO3hd zko}Kf#|wlu(r@dzSj=6VAa9pb`Air+t!OL z;ANG;g3TNefZ6QkstWqv@AI+GhPa3cKhzp>jgdXWd0Ur{rYoGWbIaxG`xyM=N{vS| z!lbxKa1aE%Z}%@_$Q<4P=fgh2-7N7inUU^8b0-#l;kh=|f7JPn>beC~P`mY;3RW2d zy7TCK)S15u?V|UBcN$QvS%Lb+zsn9HZp|w2Z~3Rgdc5VEGnNMXTrAyM>CY2v&(&jk zu5tPg7Zf22O?ox!{r`BGfDykHLi(Xbcaq4^#=Ht|07$|7?eVw2z6XVuc{DopI?Ri_OH%Vn?adgR%Q$r524RE{A(+5>U=Q&UReP*KpoE`YUj`R zYs=~GZ39CqzP;V-_&RQPV%qUt<YS+75K%qNlJ1bl<7MH)t*0p zQ)r5nG5LI%Y`%P^{Nu@a)ax|bH7a1W5b-HEbIqIDg^yErU)*ysb@=D3*C%R{%z$~3 zruG@|i+sz{EkdBFTKZw1r(vPoqnc)IbLUTZ1(*FZ^VTPja`Lwv0`HjsQh@TW(2#3& zjNA$=Ubujr$;l*$tKdP;=E)#!-c-fy>iiQ5?v^co&huJFp&qeMp-lB7Fwid}+3mZ$ zZ4eb^{hz_esu)4Wc+blG6d3kW%-`^NtIB?;=oA)xjAX+yXllaP;il5*2vk0>BrtdC zQX^mf(S(cY;*AeY>ksj_$+pQSPS9&Qt3BIuw5v{s=h&0Rp;u?QhnA#$ zbOn-RBwuMNgXRu&E8uArrEFk}<5Eu4ejeRdjKW>k?kehZ%98Pz%T&m;NQvXz`M?`wIcMZQM=9)|@OPFnbzLE#Z(-5Y9R17<(}w(gVI%}f!) z5%E!^k^p5fl!J*DOjknIpxZ!6C^Xr2rn-YtyO9n~g0Dw#qTKJdAZnH)=J_pOUCB!$ zvk!~hL|Ev=^Bys<0>4~qbQ@(^pM95MJ+%{@NR8qoA0KEC5tYPLx;6-BAXqTz2U_)i zWY8IO`u3OFty0w7OyJUfo6dpH-LGZ~dKyh_KRs7lQO=hPF<2`K3dA%#wKilXH96hR zb93?MIt&l-U(KDqm`dK=4BxZZWsA5xk9~;CciWRM^v%mG$h@m@(S)kd=AvuiXdGZeO{qKk8JX;oq#H9$Z zM^shi((O*`txBMyLU?@8E>2?(X25gE;EQ zbNpnD58iC;tW76+32WW(=ksZGZSp6L6P*jVaMj1%+ba{Ro*z829!>lXrW|~n13@{D z4^pV-UvoNH9a7m`7Hh6d+7b8uB>qV>c64^0IGK{I^I#lTk1k`_@~T;tu*3(^oKRDXWfSqyT{go?QO^B)W72hZ@8k1&p9~-uEI35hX=pOX2JlI)*A$_b; ztki}|D&B2CI~s^#)X;?pKk6v>>F005hiD@8GT-c*FLir4YrE&O+4H;Ca`t}p%erOb zo7g$ir}Z@^EZ-axA30D}pjZE8s2TP~WqU(QdHV~bucTn)9oYZSt1~}7e@W_8Sm2v? zb$}yI`)fHzX3prV?`zwfwb<%qR1S#jJAxd^as_=Uf%3He|KO{sS;)@+W~WbGzk+X` zar3d&%NDn9J#xqJo?j`;FU3+{3KW~wHIoy}1Ds+O>&d=DZT?Y?1tzkI@wpfcx76F`*Ks~3lL z;)gf=;LfAM?I&dS*8wISwn}-T-E>#<(za!kR&{=BrrLi3ceTRXnOzAjuc z083}upH%Og$I)$jt_n|%rLg3uCD}lsy1Q6gmdg3R+!XyZAjPi39tVk=K z?!x_Lg(ZV3au!==YqZT3CF`Q0xAyo_39*m@<4eCII5^I+w9zE5?06=5973WBWSoT) z48xY3D+t>TzgkAm<>FRahwibu>>>#g;MTNf!%20KD3CWl6F$z*X* zLhPv0yXOjM9xhQy7_JaI%%$&g)yNZG<3(Jnk`~ztY}dO?FhWyh?Qm4^d@efdI!(hO zCQ$)Ch8?NtYur^Ri?4B~xZ#~MK#w^dy1u79>doccT9TOSxRk-i%Ur18G*7msx*kdO zC}4-MbGiQNn%jUKmpcbD${oa_IK&fHqnd&#N}R1!2wdD`Ym~_iR|z^eFmWgIY>p7c zL6J(P@G*}D{{0V*wa#LJ2+_A6n}s=H`xq&r7;)kFGV7r8jTE_Px^PTg^q}R9fi*Ns zxSQ7ll@Rj^M__?X(S}64rQ{(uj;`G`I7-X%S%~TG2#kr6mY|K_u&a)YR^p2Y%T=Q1 z6BdawuVOu~VGT}QPgZqw7j_wTlsTJ>T%w?+_!Ln6clk`G_;MUGEn?2X7MP~KshTNbYxRavy{h9 z+Cqw=4a^!aF=8u<1;t>fc)_bD2R3o++2=99S$|q6&3`6z#@`ZZ@jnbb^sfo6X%q|v z!AbGLV!(KQ&wd9sVYQrlyfBNO;Qe%ucrmuj%|CT zQD|zvZ_-+=`lM1%k}UAH6E1uurq3r&F5vO3osuxfp(4QcV#G##Iuw%rL+C12bqn~6SmD(d8v*M+3T z(ht9}CZq*3V878OKD4ty4mL5xCblZK;Wo@*!ypGGs|;PPhu!Jq`iNWy8DaLBu*Eo_ z&_turTVE0|B74aY;RD3@wENp{YzGEpnQoX!&lB6WEz;8z@s^8iNPFJ6ZwD>qMfT3- z=1zN2kx);=Ohu%dsW6DnuBw`i27`(!s%8pCTpIkTn8csI4l-AomfU}S{zwTQz)*P^ z!BnCeL-_;#=y>w%8~pyhXSH_TYpO@+q<5vzLmk7j7xKEA3-<5FfAj3=ujS7Qvv-a0 zP=z-Cf)i3bukE~AprHKwu|Iuwk{8bIl#7t9R`^zUC!am}wIaNaBjjwT56nTQ&44Si z9N#Cc-b`oYpxJMdc$}1R=g*R~2x=A%R$p6<5)Q0Q>YW%nuUV3GXtDP>7ChXNGd~BD zp%<%u(ZN){#ivsf2$r5#1S#QFkA+|(gnJ%`agTe#1p4!#Vf(7+dGKozAd#Pst^Ice zKg1z~@$nU3r2f#K9G7=G-U1<72 z@~pi;&da|1#3_2HpB|I z?+9ZJ!zq`}{NRAJMsB{bkr9eALJ47Y!)R?U+!1Iklmhk=?|Q*kS#?>?d93EPTyD)F z{7xbb!OL=F8X7MS@|5!-_3!TQda&~lmlr2|8*z1?5T+{X$om|eYn=kwW*>Q9V zZlOlAZ$I>897ov^v zxhc~DUFb6!*4dBS+;0J2EVGO#R}_mAhl&-M?Hbfs_h8Ek9L_ozr=hOza8uT!lRI&) zn+72HO-h#b==f6oPvyN6%cWgl%d+L<<^Qq%C%tF+x4(_N&1GAcY5Zs8&-$^hE&%-Q zjeq>i?(HLYC%P8@xadc34-TyDu0d)t@!|3Hqw&8Uov*GYkC& z_D-T3506Gi^3Uua3$ga|y*U!Hp^R*oMFcuW=rf!zCsGDeQNTcfrB_RR?E}B1Q5=tjr}3gvg|8mWzLJ|7Gm!Zei!W208C5(w~{?j0jGjl zObyPv^@&JH7?=Q{gv5k_L;gvdi;9c^6(gz1W9}6U#TbUnC$}SAbiGTzrkR*%24Ic0mxP2X&`I&c6P~)Puvrc|_ z6mJ(xMYq0q@m}_3uE30Fq5{+h)!Xchdej>eXx%;}e>i#0);A!QnkG zKDyfYcZ5{GJP~bIqN+k<;vru}A0PpMLfi{ftTfOUR!*gHZ)KOW%U0`d#pl49j_pEM7K^QEndX_1&(acZ#la7sT()NB4|Nn z`Z^O}k1Z{Qno6ZB!hj!_Fj$4IOAfPz(=$kkGHEg;*{e?Mc+?U055?AQk`I3uiU7C~ zcc<|JoQ;k$b0w~(7yNVtY55A*7R%-^88nIEUu=FRLtV-ydyMeEAmeuahYsd&h7|9A z%ajesCnT8`NhFa(5=kVH?8rH3#o1zWm<-SE6qRay>rBs}m&ql{q{)!tz%O@$F4l&OqlZ6 zkWQgbfjjQFWOz<>b*2274Ym}7=>-`eA`sk_ zIE8o<>Y>LP6MIW1%oZXrsIszApLn*;m<16SBrD)m?8@hYCyodV`azXF8Fi-`=R2bA zn(mkE%HQ8tqy-9rK{Nam>PDHfh`=COVCo*zQmCm^Qm$4sn2Th@tI<#W7K<6YPiS$A z2n>4Za?6`74O4E(TUMMk+1L~jw*nI&65DZ85wQfvP&R@yw@r1L8 zz@X$bA7-9;=9y<65+q2FAZe0vb@V~X$qQ2H)2D<2y*FEkf- zz36pm)R zO*Xy`jFERlV9+Zm_m}(2{pJ30zt)^V1O|N)YwPGtk&#JL28lYx%n^Y> z$;Q?ZfkDZ})DZ=vQc3BPonTgN*&Z^#+us+%Q<~{EWgs6y)U7%KJ zB)z+GWk}b1f42Xq{aq`NxAPpL^wOp`JBo&->Ft)hrS*M`DxDAogOW`n5e?%$O==3z zL=#P^jcB7SYx5Dhd$te_ky@IE@6vJJ{j#3X%UT#n=~R1o%Wy0O3PvT_pMY^3*X|(M zbU7k0DA{~+M8Tj+%FqJRv-tKVAG03~dcrovrdX79Qw|6SI1L+_BU|4SoK%;zX4znC zcZCR|Vcb(92OvX+%)fMqbWEIZCKuNNI0Z)N{B+1O_D=cBiFaP^DJdMuLPNe)!>sAAZd@g9r>BW)#A#4%^^}uQG$m%yOGz zQ7R@=jZ?X-$0-y}d>U~02iHOoUI*yqCN%C$UzRn=hhZn|uEh@NPnolJ$LQ9^oQ*Ad zX=z5)eDf-Ti6a7ogUkhqHP%>TjkRTMMA7y$h`^vE|KmgT=hTl0jDm+FM8Tj#t-*7H zoZ4%w0YI3#1DnI=APg`i7@i2BO`gInkwou?Fhs++4}b>`9z4k@4dm`Ve+*GDDoNuC zq*89G#%`@c6l$oFGBvrHZnHf8LgdkH+pfIXu0S|W$*7KF9gXg70DeG$zk-iq%jhb? z@MBsEHI+)rwtwPQnSn*AnoKSmdLZI*V3G>x!+*7yR``g+|L>kuAnNw(u}wP{Zz{=G zx-10z<~P6j&2Rnt3E_Wf6UFYHGeo1uiwNb0gp&8|&;r7V0l4D=fOWG_4M{#0NR zd0O=Aus}XnL|{;o5qCtvpvua2s6?|xLDgh(*>;e8Ojg+mQ7|akXgeY>DA{N^A~5JR zX4}Y`;kJ6SZ3aEQEea|oQ#Bb|){?#G#Ah)-Z9iLxz@XQa9XeROZIMwmWkD{($Jgcg zX*>Gf&BuY#&|>%~9YN!q(DH}xy+={^=s^A`)pC2BIEx4j4l-Nn;aUuIZrNW+d*Gz9Kk`^De0%ctv`&u660AC7+ScdGmrb@z`RKX*76{rnQ-^>e8_ z>Hb4>;o@Y4=Gh<3#bA~eq_aNo7wV_#=wl=27}u`*d&*v$@ukS4+A|mLhx>K5^Y~1E zak%{d?{YlXGdp<7$Z4!TAHB!-j&*h$`{+x6p5!_{|GOVS?tYH#_Ow*t3YvlqfEREh z_x}TcQJ=8)JzjiD_dVj{96A0XgvGuG7yt4Q8{qG(* zE2r)$S!skn3|<7hYXh)A2}A+4`!pGlqe6t({EFuz1iJEN<8eK%l`Mr*HUko!wq3h4 zO>x&i=oUY(t_x0QVIL>HJapGpljy7YmlnBoyeLVA%kPTV0Xgu(v z#!CiLQZs{$?g)@?|8MT`2=ckoM#~(BEaq@D1+V3ExvbAb>rli=ngINCiYOArPCg2W!s8YU&Z`0DwmzPOY{;6|$CK3( z<@is#%~`w4Jwe2pJACn=9PeT8C%xEd&JMJAyPFT0xk_a+NyvutHKAaA9=~%aI46Rh zN7D@yMaIM_&AEI$3E6nIzFPMFn|R8EAO{H#IffUA%E!$I9@4@SjFx%q#GHa#CP5hS zy;!(H`~Mj4iW+o^Z#pv)AkP4>az{Kb2fk`WQ>Y`ji8x1{#g{DS&>4^nRakx^G+iY1 z%Dw!CE4(0U8ggw(lqMvUYURG$!cx0t;u15B24utvecYGhfB$%z(1qq~a#(n0*VKg; zVa9HfOi`qkY_vwtL;5T+K<_Z7i%OgqbwhI(K9n`oHh>KaoKhK!qROA_JyB^{6rB^O z;JmeB3n0E3OZN+xBX`^n>3+zHEfBg>#Qi~T%2y|=hXr^XVb0j_Wey&wS>`%zGiaJJeHxD`+xZfpB z7`f5%Xv-rpCD3SX#}ahB3xXw14XeYPmSa@A)`U-%8WMZlPTN$Z+eieII~HHEUaYHn^CX3|!J4Je3@AnCpT?#< z_U{f`DBH3v+j?8gvn|`Qt)}hlgiq~~4xfB?CA$WbM&5upH0&6-+_PetlZ@;tUrKmi5@H$F9^YFlIfw=(-0M-bs7+8S-RpyZ#M4ph$l{N4)5rD*e zkarDqFdr5|Am)n#!7|?y0KQJ!X!nYrL1`bm-0S7M|W2G*emQ`yVcY! zmPB-ZoUf;)7+#K|+Qcv_MQf6x+mH=fEmLpW+c{U5V{!)tfnq_OptqnhFm`(Kt|Kr5 za3E_KC<0}JMLr{mR0I)&{xFwu(U?$ly7^LSac>h~!X2yOQ-I)rDU1rAP`mV(A6<WR|$86LP_Tm!+Av%n)cK3 zh;RQjgHCf4Q&vShVg)?mego_-vF_Nxt|O8q85T&qU~5nKhXk+2t^O<1g$Lw!!P42k;S?z33B z$o;K)5Z{zpk5vWNbzo@|;;c28|1lB}L9PI?M&0r3xx)fGCQwbN4nrxFLMgOtWv!dO zhi2VBn6=u~<$q$}igI`bzN7_wPYwUr>V|&|6G$Qw+`1AQo2qgFWSW%!B+sv}zooj_~2r|)bSm0WSADkCTf zN`jK0B+%GTEMmV^B7~TDS#aR(wK}Z^O<6&90@DO|sIU;_ zA@#H}z$8m-v%p`2VBa0Rn{Unk`--1<>V`)es*rjkuLUcP!b25JL*KYCNg;wfnlObO zJP?8epkSb51_C+~oX1F5hGSqNGSY&GzsOKzoF$IXf)4EDR!a0aNCcxZl*Js+2kD&= z`6Bt$Gs?}$rB%zgy^1wl=4D@A6WQ{#bO&~92few4cDocz znB`Sn#zM(W0g6_NFby=(N~BZ;>Kh`>4!P(N+uGA9)*X4XIa^LV8B7DDpXLfdAgU10#q=;DiD-~fXH z2Y?Wp;IPMQmtQ>HL*L@g5B`>c#vH30b3``XOEp&XvBr8-=HI4KWMv+Pq zqOQhrwUSjWts>SZm6d}hZ#FJI+K8A;X(IqBR&rK+_;PhO4{s~B6t$e?qxrKbd|-Ny zg|2_iYToG|hLTaw*vz=a_=)isGyoz%d`Jg*pdx4$v;#T>y=3B;1ZJFhnt6*gfJI~p zSv9PQtPa*$b{)Hwy@0)*{XGZe_&K9EGdRmRSGW;wC3iIUQ|_lcBhSk#<~8zm@{aJn z=HvJXzluMHzmI>D|F-}q5D7{ID+RrRx57a}voIhm6fPE?5jBfuh?a<>;$h-(;=4-& zd>LF*W9rB{2}a;9JccLm3MOF{4&Xs*h8k5Olypd92`&*OP?D#bm48vnl`5nrsn@Km zL-yjjPk*%C@>k0(@{Mv)K9_gpfqc-aDNM!RsQ`*dagZ~W3Cb)bq|_)&l}*YP#B~zJH0o7r(T-~6)t^Q5@w`P!rsNrkIYp!daY277bhW2yqS?zW0eeECG ze|5<^l8&#_=xn->u2@&28>?HSTdUiq+pFtC1|tPXGqMEPhU`W9kn_m5$Xk7({#!H; zy?}m)K1N>{fFa#LH&_g#4JQpRi~^(H=rew0N;8!g{&#E)_LuoHOO@rim1Wgh=UZ1> zw_49xzpZDkt;*JA`^NT%9cS0xPGgr{PiF!}qv%2Vc?BHyaEhxu<|Cub@KHwcBGJky znaNWYa;5^G2KA&Z-K&Io>}7aA@lkyX{dj+w|1ga9(&!xGlUOyd!)e{3QG?k`_4;9Tas&Bhjm|0kNDIJEn-4V?$%L zvF6yc*n(JZ?7O%&J|f-{pA+wje-VF|*MGtND6>R+%)LL~{bj4|v3w?9%1itgyJ~;F zDL+N2yuGr*`c(D9wv4v#TCHt{tz&yKs@;D^&inteABsS6C<8T%yRkYNk3Bb5W&g~6 z*Z8u-$HCG8?GWgIcR(B%hsa^axRLB8ip1s^)p-E=X5>vbmX>f##c`qu5vS&tja zEyYc6o6h31?rd)tM1cemB;nA*3T2RBp&`IVILO`LEH3DNc5G6MTgFDVH`>Pbt=;5o zsH(Qke!YbVI+!tDyW7X^J@)BQ(Zth5Hv7PtM%Z>o0^vgfWsIVT27nQ^sG-QI^=GaLgTR zY;(x16zDKxW8OyNEoQT}GRR0{O|!r@8tZpm@tSA7;o-l&#i38~#rk3&TP~~_HVBKu z!dME+VF5P6Zr>JCKJ8HwL~W2iKn?|esojvp3(P|U`m|| zqExE#+TP`@4ki&Ar zoRyp9#&UCcntVr|lh^0JE2tI-#Vd=&C1hz>$+z^R#9(i=^i%nQ^4RiZnOG*5Pa%N> zB)Cb~3=JU*nn7c*2#mlE*ce8^6Yw&;SJA7uR<^66)dSVvYISW^XROQmyt-2Vr*V2? zWFxO3G)^}58plYCv>*&bj37uHaX=m+XXqc$MD#!GO6(&HU?*|2svN}^@jd*E&_q8W zBP>LOa3uT*9MK`)Av4HDm&uUaMsAabQzR;&4(dT^Q65w(Wi?+QwX^k1tFnDz```Y* zqdMOXg&xuvI);X6isooQhxFzxx3#v(cG5khE7+~D3IDdCbzX zAJ&w$V?EeFHij*;@wt1}&(V3zt#P&d<$N0d-R)BXnM{af!+VFzMz@cwqvmS4JeC+w z8-J0mN)^&0DO^gDFo}_XG?l(e%Mw{Sla<^nOJs{&RR5BcD=YHv3KT<8Dy2%HdYu|p zL)1P^QnP6;+7wU%0pJ60pabS$L%*t*>96&@I?0eR))=wLWFDHSCa`X`7F(1RSi3B; z4Yv_{sdKG!&PBR+CfVMF-i`m9AMWo2-a%=w4z@$_&>!N$oe_N`q6N{u7`^biFMm?M zT=v|0x~%uM7f$uTeO~`*g^pYO-wwzh)`E}0*Ml&~Bi>O9qy~qBU~o1#IXE}CJ$L{= zwmwHFdO=B4L4EW<9teYQsEfeO4be&T7EfVCGsL#o8T;aJjK@{n!#3W4%Q~U|iIYD$ zOiuJCI;96{Pkm@4&8IyoQI{^MrUkphkWJW{SurQ(!@^iRtFbl{naTFDH$387Zo^%; zACKUPydka;U%9BhR$GNruKH1jx}|4&t8KK4_S2F2gP!USyRKmxF>;H(gMNt;YTX7m z;XVv8qasv+YEo0`h#C4hGDF|!YP!swdhDy&Hv5Ie?71s^5&i(D9M5^&pyD0o^xzgfS;|Oop~2F$Lo-R+`@;wzXr^5|B3)6D1clC00KGy0&tnu z_n%yp_;Y*ZEMR6NhUWhB5X$=NM>haD_Wtz$j=lH2@14}q4rs)593KgEbCAY0AduGw zB!U2BJUBm!eUh*SJzyKBV*msVL}S-`irG5m{_cHbFfCo#+b{H4EW6x(r$M#1w{GLb z%(-#ge6=VZG1;R37`HRoGL(kGE_#!n>wzi6ftE$M0to>?0Lrf5FsKsgqs()87?g4m>GKAQhE1kUC<>A@4T99)+skW`T|O z1ywnC^~tKQIx%VCkh`c@Pw_e?=Xdx%k<;S? zTru*W7%Yb{8whv1!I=y{{LvqvN*bS!M9 zNmvTXAOq?}M`FriF-zoCGS7>#@Qra_x2|gDU&Vm<(4iG{!FcW52Od`+G$4UHY8hhB z2c#@W$Vix66AmAA@qj!biy-aB32CgD!=Y?x9SkR8oX)n$`z=hmK8O0X%5mU!0v16L zF2)7|Qs@0~_313?faF8Sb#x+@=SU$7c(5-vWDcQjQ@XlH-1$j9zM?M+X3JR?mu$0X zE?vh=V(byjKmxu3&3gNOAG~9Me}O~>-adWIhk?&8O?9&xf08R6GbHuZN3d<2q;`H@H`eT5%kY*XIBHFsDlmN+5$b&->8|@!|74 zbTIxx?`DAyDrniZBB=sJba6H-Frl4opr1SZ5M8IywV&v7!`zp_t0Onk4in<$CcN^C zf<#Z$5dyMe8+zIfSL9yn{4N4+6Il`pjntiVi4ayEp%6TRoe^H%rrF{I0;Z;PveM<^ z)bU&qmtYQ(5F2pH$YjH5w0uX;_KE}~8#9LnyWNG+Y2^IEksrH+dbBK$phat=N+~xe zHIJliYZ7gN9*AB<;AS6yo;vIM!V_EXVA}8EeGO~tW3L*W_)P3S_*}(YnhsJB&p4z4 zzVTCR9C4^+q#$;iz17ub+M~1oIGRPGb`}qp9&1hM&K~aVUrC16{=diwWlhOSqEyp& zY0TX$;`KtVfs4blP1}icriBlgHi~d&;rKEh*Spg|sq@}jp%sK7ItmFt1Nd=w7`(Lw zxf<6e4P7CMCl+K&`eUF4)PO@ALq#J?5EdkF?V_R39A6x8)?(nWn+bJYbDlcYxe8j6 zBL`f%I_7OcGia}3_Gj^XjW|Gwm$VvT$z=fzrk%J&N}|>n>*~rC zjW=W&(tV7AeW9+U+wg*vlO(X|Ru@TmTM9YVZ4F;=sZQI(uzWa%H7A41YTzC0u{TT> zut88zuYd@CXr`d*2`H|mq6J=XrzoZ{bl^e|aC_eStrr!FG7|D0`Iz9X%{XQ-u+S(0 z*bb4$HpBMN1?pM+j<0&5+u}4KI%t)jovvI>V39EbaQ6eFt2m4p8O)_u4W(i{EF&Lk z`)0xF|FE{^Lja+c#QeqtcePKseGs{uvh!HprBw?+P=E!88?)E-no?H`d9u%9-(TX5 z$fVhX;%loClP!h|uh9tEh@m??*n{V^;Op5JhT8QOSDkDh#=b06y0F_Szj62g$a}5O zOH=fEtwY^>DNJaBY@kr$p3k2eNf{G70tx5`h41xA|IC7R)?h;adiuN#{qG&PoKT>TFQa37Zt=w6 z*8upD4(vnx1X%R0m-*oQ;3gG`K|l|>`>eej!YA9i`e}PD4Gjzza?sz!OE)70e{=bj z@@iK0EhUaQMZg6w&|q+bPp;5g7w&)S;Oo=<#)G>qOlVCC^`G`;bl6FDB$a+Uq<8ua z!^b{t*bI45`zl7zUc;Zhs-%DcBM{R_8sy|h=pgBnUK})tj?k*pji=tToV)k|lIc)^ zFt8JBd;v2O9WlJn*&az8mqc*fSZ#6tXmijs4^GHa()c(3%AQNUZy&UkHMbYO*J2zo zg!Cb$Jeh|7!?}j&30v<60Cg*c13Vkoz0d`Xt&^vHXd%l673(Dgy-XOfuP!>Hw^LmX`WUo%_cl*XDrUFek`0C zo{6E~oxq2juABsx0rOhyof7neGaqV$byvS@I0ZFm=B+sXDc!=IGRt9q71*}Qk&^As z(vW*!Ihux0!zu4&eVt8a;HB3%p`5d1-kNXD7Ty4#w(Tn{DY){C%Lj_z{=D*qd*$Z5 zCF6cdoq$)bob^M2AXjG+zYj`z$t_tn&*quBD3=@T`dsGsbe<+RSderQ_Gd65{4Y_Q zKpc9Zv61^Zl!Ven)8LFSQS<;1i*-TrK3Ph_o)TUCjhGO*Apj7U3Wxx{xPjqRuZKL2}1+U;07JmivQVc{*jhO< z|KZ8X6v>kbq}w&=tZ=mM%DTwRYTfx2hOx`BrWyu_kXN63w{{c%ZP>|v%anDX{&YjW zx& zJ}9?ePxbKd?$pWq&6EE3$!K;0efn+nblCp&(Uz#>h$!P35Bs4}>0lw++F=N*KymdY+`+m)echIf{Z zJyzf*ok#`n&2fus;s&E}k{1KrZ_Qjd?qyNb97F8QP&)JFdL+22JdJ0x46ix?xK8AY ze3g+!mk>dKJM`u>)<;BwE_9JFrtmlfV&g?j%c;Z*M9zV?p4V%rau9$b+!1HJcJN&b z&&g6YULm>gi4*JMjh=jtQ|G@HC36-T8XbrTHkSemMld`WaY9y`&h)Ytj-?zD=d0WJ*M65ddzozSq~r8l zB}rPm*lf?PjzJe@7-0e~NuPE`BgBYBm|p(m@Bii)!r&< z+0ax+*I7lJpJ-ln9{rLxwWRgpZeK(mskzuKEWf^6SZJJyAy?``w5sIgt;U1mPW-bL z1V<>I19Gdzs(nW44=^R)lY|ASH`|;FM$iG$BL0V4CU&nfT%UjnE_=-x!Y>*(-Vu`j zw_n-*ExGdNcT|Nb&{a?Ha;V|SMC-5refR_Hu~!OLjJJsY6%YR~4W*CAZg%g4^6cDD)vzc(=#VN**A^z< zBumJCzT_$pI8IyfI&TJvg9BUQIN1)CD$|W2FiFkJxM-)&l9NQON>nfsYS0tF3frts$sVpgOh2B^^zCg@$HU+Yyf3<#I+;75dXv;wl@ zorz1=qkelg?Hj?;$CH^0DmWQmJ^Zbo=Zq|!khu1Yghv-s#^ezpbb$t9T z(EIz;?7G<(iT|HIg|6WM^8S9GD#NfWoJUeGgPytYwW%n3sQZ$@JN(|E3zjazl6OTNqT!c~P z$^gBE?s5mp%d@-3Gd+Wt3!k2w?HnEvvg-Cc)|QP8RiK`H|JA>zF@=v?+n&-nB?O~1 zwAQ^tP0*)aZ{y#*n<38TlhVKY+x}p4`zMwHUp~E}4i;y1VxR;fQ^B6l9eK1ha-|6A zd`#prs63#w2vS^0=98F=c)^U7tE_-av7~oOgQrq-PMAn*YC|y+#YWeyfAZoNOuux5 zF{Yh0L%6@aCBe)DeIJkjnE(>H^vE}@oQYr&_e*vZ%9a%u&DhI0Ovcdxs?YP(DPuK+ zK|b$Z`znHiP$VU{gv6!y&WC_(a>B%YUTomipj_m+lUP<18TUC+=-S3}AdII2$KhdV z$B7hIN`nWhHpxe#Oob}=>5HG!{ULGQDb~&meb>7<5!8I_C*sF4g7MNL-<)QI^Xb=Y z9ClT2{eo+FzV$dBF%JQ%VT5Ui$MD(XJ2? z=S+ZVbokO-;YvSdh1&mHFs4@Wu;9&k^Yvl}8HeqemV7+t@I!Kaq-Y~O7T{(9Zo3Zg zdJ$x7GZJoS2)})OS#slYSOF@D1PMd9)QrIiugTj*=N4z1%`{)`$!L`d+GD9yA7K9KJAd=8nV#x%r7`F>ZzyNI#e$b=VJT&y5eIWPEwxzIcDA3*8 zhY#pn9?{YL3xNJpJ(k4(dF{X_zE_u_xOzJRa#M3)0PB(nj% zU=$W%!cMl4Erb-#Fd@DPKQIT#nm6B4$|8|Vv8FoM2B zn}l#_)>mSTCH%wuZ27*;BBseMefcgb2nw8*01ZlfK?PUon_V=d-X2@0`*}`qAKUGN zO-H(NlOo&-H5lmi`vw^da^j>T=7m2ck!5^7PXCkc(d_;EPB?!)j(I*&zqW$RX0uij z;C)E~$VYC~lpl(<8Dcp^9@!M<{SCKoBOla_dwqu8Yz(Kmd8J)Y z{6=e)XjnHEBXv(||giajdHV!yB_A4jXhs@$eTH4zF4{F=k-abD+ zlcC?js&sGjENSKMCbdHpaqN{lZsarb244cZ(H=u=(^BR(doc^hEEB1WEV(=xBhK5iGRZT63_yQ#B+>Q(H{}&)Vu=B@842`hi#yC6dQNgx$WTq8B2-0OL+Sye>Z+iYdBwRc_Cg zx%~LUrRyb#+Vedlco|;RFZ&DKnQhc|23SK9xDZk*Zz~-8Eo*2t&r)hOvt9BXy=@(mv2p=^wjT zIF6=nKGHPZwBu}bSulOy0GIte)lT1Q%FGe6h(@Gx%HgMj6xXA7&2+qVVDw3a>>x`!VGS|mr|A&glPg(1Yr4TRmc|RCzg!J78 zY~Sk~2HLbhwafNQUeluJdZnP0b*l1%`(UQDsP+At5lygKUTe^N(FrLZTT%Q$3aisuPBe!ks-GoxMx%MM1Q>&jeI? z!=w%fkFXE^4Ku*!7=Yf%UVEyi*LWxD%h3y#SZHtnn2O02t+>D}YFt`9d#Ke6N{ zqxD;csO&k=P_OTW1pQ!#%c~EivIxw}O~f8bkb+>EW=kv*93WDa;D^B?4XB=A`@SRA z4m!f&Or-as)q|{1OYn$;`yGIx39^aB-fz=dGDLmNr3H;Cw2el!8Ai0S7Sgf81Lg^6 zcjpp=<3?XRsUg?PKm;lcL{QCZz(|H{?@;~WTSg&%zKKMSxf z_s_k&)?eVObuLvOJ*Jo-;f)OC-Bq10FVISG(orTz*;GqbX&Y=KOnQ0Xye5J}@|YBlNAYO+)!5wPhb z(pTs9XC$hwyY5JHTmwo_RK`h$Az_DfE}VIzbwsrj8Z6M1%K2MRA|`_0;1I zm@6heB3mL%ge_qVAt9DHMU4PhKo!6=ov%TWdSe@P2Yr+W+z0XtEe$!AN&R!?SK!PvKpPQC0~Wu8Odis zcYJ=jk-S3dn8du{Nmle|s<?AedbwoZgCeYQT)kI`E|X`R=bMbGPVFLo+FitHO2%Cq5oISKq$LoA zVC%tpK9&_l)#~lUUY@I}q8M;&W`^mbbvNrXM>|JUMUiGI0@32M5Z9B<{>G0XB0&2Lu1e}aJMBLGG1)ugk2&*FZ;FYK*t`4yOMR;aa`9_s?m-)(8pMr z(r)|U{L_#d=8Y;6f_8t=9YZx#C@ybmn}{m(~TBM>bey z>Th9KB4Xkyl#o(La6F|E$+v50TPn|?evCbC6*@H0wl>Gva9dE{veX6voI?Irz)=74 zyY>D3FYgcpQ{5{>L71rFH`NFJ@P9aVNNWu{Qqyl~Ndr5lf0%Hz)j3Dqy^R^5iSS&y z<=9=j>kG_yeK!L>P+>stUAM`(7b`EmntH4gT^BN_FjR71%(f$5U}T8t1Yioy(E$ktu={-gx z;J~NU0F0Lj1w@s?StW7Zf7A z|M>G+ncdtNuVFtFAf4gI_tcO;ziQBmqUTQh6^{FMu5t$mZ)J(f!MWt2R{wER3WnPg ze*b-*&TYq#eR&m4f!g828^?u=t}V$6*$uiDE$%1py0`7{1y7O~jHKS){@Yz$H*VxN z=5~MkQM?P-9#X6a1dwhvZy&J15=8n)r-BiPm-yTwB@ud9MCffJ(2ub@t(_!_R8ZV0 zbSc{9w*@I0kfB>Yydzdlwd%VjuYi`aaPPqy^ea<`PzqnW=l6`HTuEN~IJ8EKrWX%F zLe!edHMnkzrgbm1$~3Mf{H9)Af<>oF=E-#SfZ1rU`9s_mjZDZOW|t&TS-8Tq+VlY& ztM3&`KqmGGGcsf6FN?%P;GsyMT!vNxstpm<*#km?1jRH%@4RH4g8m*@W8MbMo`a#a zZE&4rJIhO!5VNvm>*L=BSPGI^Og$=2Rd5`Nke)}DZn7h=?l&ZJWrumo1!{#`(webD z#vz{NZ+0SWVJIqjs&j>oQ<1Vk*f|Z0PO{9Aq3(dqY<7i0d^Vk2NXH4#vTQvVxvO>! z;lZJ%S%(Lvl3v=@OGoxXkWd`k40IRD1rR+TtzDL=WOck$!d6klUzIiBe9m#Y-tyvO zB_32tJKXmF=d>%jNeM2ak(UnRyWL{eHc(?aij&N#K)3oi&1BOxw=`(-Y`+)MJ`j)D(<%+@j5GR|}#+@TN8vzX@5G%kg z>|rlw%XeQ(qnlW9>R@L5gWlEr$Za-W%xDWC7@SCxAPjyN`TuFf8(vF`G*9;y$C+z6Lu;9nBzKycbkAzI)unrD|R7G;=@=zD=4=1 zpPv2qb4ceYc1OU55p6?A;v$7-KXC=|6U7L6WyEwD{RM9!ZVfZq=`*sugVP_13+FFQ zqW{aul}GO~B;IPbDPFcLQ@*9iJO_Ycx=9kd@NSR8WzroU%?dUdwye$zR1n&CRg{Gn zlP~<6^;~(B#$&DK9~hVgnN(K2TS6Lm=W^fgTGmxDNwT`=udB)@FLYzmAbVxKGDfn^ zJio8AId32vjaApg1}ois8m8^_&{3(EoNUi^TKCK0N|xh*g$BD8{GVYlW0#VY@M1I8=5v{8M|+hb@F&k@8+n&HMF zp=a%PRp12$Q8u%bh)#uEJWdQGfJ~ydFO2Xh?5L^O4VkDuym>0Ft>dHUNicQN?4zCQ zh~8@r^S3I`kK6aHvaZ)5dF~DT!&&(*kSsoBLHW(*AL^jqRr&=F(jKdF5!|2~`Pb90JwTDSTU1Lz2t#0AGA<#bcf)F$kW&V!vtcA|qS*B%MeRT{ zi$mX*8_Fzhce;ycoSLUQaYs$^$k7y84!fgKH+2aFS!X!4a|h|N|BV(x;8|IS&@co5 zk;HhcFpkO=(%NTA7{;>^+zb;C9{Rpzv?#4yjsG)d<9xW(OpqzKv39OWQ71aA8iG>B zn`|^aMkzneg%iN^%|Qg!-H!>ZmaIz~xZE&yU;|399LZgi#L{72i!QW7ztb;Iy53d# zq*#L)_<@~u?UvbW9WG3@bEA;K_qBiofa-->G8OYCzfuPef=n+qX~R-8BsdfZAj?>4 zn66y!cl&c1zNqV`e`XF6OXSC0r-%b`3zf^w(#hW!R%5~!M zndz#bDUwP%%3L}SA?Sz75<*(1ZvZbG>qKpDjB!n4-jtVQ^SI34FV$+_4GGD3X0=EtebUnaxy zOc8e~bbeslUyw^FBYg^8sIMtoJMqSL7WFoGTUiq#jc>C5= zt;AI@c%v#?w!P5Og@x$DV~Sv})Z^8a ze1PEzNOwb33$N_-nt&)FoiQf#pYC6me%(!{N=*JSZKMq~S_i0Ai)byO3yw&7&yuzbiVnVH{!^szBVQ`r7Suu6AqUbP z^;zxcnV_yDhiK?bVW5uKNZXt&`TYL^IHD!b{X=UfH+7ApEKyW`vKrG`v5K}9NT6O$ z^~#mAclGP~^Eph7fv^>(3-!`Vn7^jE3bF!Md8N1Uo z?OrIX_S*zHYj;j2p2AT@F;u;zJyFDW$+Sh$Nt*r1oaMSnU@0PO2NqE{3?H~ZOM!$b zcgLu&3MLr5VybhFDqHQ7K2asA&csoQs**KDC&8&o;4T=3v2=;t5VBDwl8^;^rPium zAFVHiQ!!tvh2fkh9gtlQg%3kqA|Jf9X&!_3TWZ*seW0*7=Qs{5YOI&`7x-mV%t8U` z!_x7&x?h@_pnLJamrD2tcniFLBhB@4-!au{Q>!Akh9nzKoum36fLwpmFM_y=LBtasKC9oLf{aM?vKp^FO$V4{6KEl=#ehaE( zt&J>$?W}ffS11gpz%4Ke1H<_fCIguTOrZY1ky7Z^0E%gcb$X#z+BCQ@UWuYjc%`Rj zj|wo8f?`DMm4*cJ53*8qK6LxTh^4fyp(UmCyPXfzS>D|IZRzK~I`i?*UnFTQBXk+8 zhUZFhdb{bmZr_O8#)NKi{h9JaxZ>F&`Kyia@)GniWKG0GZv8XBlcB zZBSU4PLh&7`ic~x^Nxx&Omfb+l>pCKYWU%8aqceIvw0sY8!W9E5gSr44@QUfJ znf=N$>=Mb6o+!8Z1PBA3A;}(4k{0e|bW7X}5%fFm7!E$cYzo`u_rbFvihIz4$V~!1 zU5YHdh8QMG6In<1PjsATzLLW}^!Ns8t>tvg(<6^!fOW03&rA3n38o`w^7T9>WzSX9 zu~M@uo7X-SXZPTO<)R^J(@MnKWzQ|7;MA7k_@9_lUiXtSs8$&}(Mj8e&eP9AEFFCv zLs!(0t2OB2A#w13B1wV-$p-XsKHRwbGW}|F!k=3hkFVhgb%p_&#LwB+Z2a1H=qv7K zbnwLn5~`dX=om>DBm*39z`hE=8*jd1V4?!(8q|3*Ym9_9eBuH&pwjIRF$I%b*xKHK zyxzcz=eT9SrOeXOWDPh?EA1dP5tebmvnl>T9|8rX0om~ED%4`|Vdx8J9?vA*H6toE zG1_4TFeT@TWq!zeknx}X!tQgmw0rnhJ5B*h4p&1; zF$CbEYdkXxcht(;@K`WryqmXUF%YgqFR8+6-%M3J_A;J~=Sd87Q?ge>x*EG0u}o8V zxf@sXgY7RNgr!`A#**U|qX;pu%1>r`N5tcGU&K-ukpZdQ7UIT#x_0-px^gMe{^69gB<;4Y zh`~wg+O_LoAEncwU5$@&Ly6BYrLKZC_&!gfXhaznkIlev5$H1Vt(V_{*%SsdE9CFQq}eM$rFB)ObDh6Et;f6r|0sa8N?1> z)k+`U`-ag`E8W?MVu-w_c!V z1PJO;0TI3^vs4jDE~+X-gmE00Kvfb*H9==qVnl+@8SkC5E(}x$y8T7iF~evgM0svUf?vtw!weoD-r(_}gU5#*Kr)5k^Xh?D z>tfZU>WXzmHoTFOJ^rVa63YOpSQ8Mhd;g78p# zyjBH|+Lm9gAD8RpJL{hKTU{1?k#YOH)j}@u0X;Gwl5g9XfqnWdxvdJ~c0>ixApd|nn zIiZ~(rVa?gE)^>M(G;h2Y2dk8qx;;G?Jg~3e;ewyur-LinMD-{St34%8Kv)QA)#xO&1qT4;f*b#ymjzIyG9h{l!NG939 zc0%t_9BoPM&0dc!v|^ObNXpw<%hNd#K3C%*A4$40JfR3y>M$p*n6wJ9NXTc9@#)Ef zFbvfyC45kYMvrm15Cxx+k%6Z`+@UfnZ6Jb#Sp#Ys|M?wN(^A#d6G><45X`^^eg|Jl zvrSW7o(cajWg1|tb6Pu2L*I&9vBhBOG5`$DYSo~7p2^o14cXq&UAvM5X*MLfan>d- zpPH`w8&?uKN!6LCERsvkt<&IvS3TjcuE03RAm^?2cNys%m(*a8cg+H_Jz8qO*6IE} zVgeOsM)>4RO60Cssgo8U7V*IRzU<)|9rdpIl z&_fY+i9Hq@KpyV+!;Wd92zk|OU5bRE8YzM`H+Xd^WEH2?TX zCTPnhk^9YWX{JGHHDo5c#!F3qYsPx!j|u4HNOU7&G$>A0Fvx!az-h`ew#9+3w6Byu zmkPu!m)3D_?UQW-`0%-<9nQCv`t>Sf-}t0aD?LDku%>*Ci*Ct3=>o{tKrEx2iHZ-Y zwTG22BWi0`;E|`lqFLL7M_&1tWN_Vf9Z5dwe&uN)E2j}ntM4gn##(C@aT4=^E9rm* z;3)#^27P5BN}Y>VO=n~O;P5z2db4lsgma|l#~(KKOo z&hkTMvsxc}A0Gz42G+2})z7mySOCnPh5$4H$+gzNxDmN*r7|aKwS|TCjjwlhcO^-Q zU;S!Fa{4>-2e0!8@8o{$DLiPp)1mHsw@wDw0h5m7{bUJi!$T}%rVH2$6>CIDnBr%% zcnr4;Ixs~(Su1b*ACMFQd+xz(1^M_i?X~f6`RWu8kSlha0Ns)DB@ddQlPPOt+J!@X z_uGrVLWyEN7;MLliT0LZ0eat62Ea^%HnPriBadhOKBmZaYlM5Vpbmfh>_D7xZJk>2 z^Z38L4IaZjZse{HV>U=w*Tp9TteSopO#wB|R*+N^C51uoz?)1Y#5bRElRMn1e@n3M zra$oQk`4d`0*Ux)k04v9x)nv%_wO+r5H#EO&$QK8>06~)*-&-Yg??(bvLYJCJ)H4f z&eos&3RK#N>X2KYx^SzqpZD3dbuQWQJ^1nvK4WY#Xz|!Rxy%~ZO?{=o=5iDPAxaT2 zvvmT7t;nMZs=Juz$dzq1pHvXJrUccF1e&-JYlOwYff^XZTgps#K-HV!DHK5l0EH5ou?-WG_BlXcQW`|Y34J}}` z}vbE5bKx*V27tg#hc*|@;U|b}UiY~{`v@HQyiZww zGH5DT#d|cP#e0Erbq16MQr2PgcA)DA@m4SApCT)@=7em?h1pp*p@UcNNy!ckGnbkI zgP|5f(*h8?arUuk-Agq@$e}OU83n#jSlw})DY8oDr9}^uj6}WA9=X&^PMz{{N%^QM z>x({Ag~PYP>Qs{c@ye5(+H!;-icJLK<{b|=6a2olfBjTLn5mcKIDOQyF|qe z@$pQeJ`_LuY5}owm#8sTA%a4{WEyxm9s9O8xX2_}y}7x(!HBI>Xd5*P_}HP30nNw573%bV zxoNNuMpF767xEAR?|H!XT~va>=En}ixR9kuD3t;cFTE-+YX(i??r!(fz_=cu?r|n1 z0=W)PjYIX1#*7!Wxk{L1{q1@ZofXZG;jLh;J3{`bOO?+(2hgU@xAa3$d=cRT8+cu3 zlZqAhoY4Y$&TJ`GO^LPm3|l%qzuM)`X?5}D-A{j_Qu~h|AFx*3ixZlXf4lo)TH`MKpU<^VYB%S|Enz~q`$iAVrm6!< zARsw30CZ_W(3`Jhqk(`d2lD|vaGutw<&cz$@J^@KSQ+a8{TG2`1bAm6TWRFXgOp-u z$eJ)~#B)PrKpN*tjKms+VJ)u43FG1U@vv5N5B;0+{&uxBx_>K=&WT#E4L$XcR(-^7<=E&7D?-1+=TLqJI z)7eL%KA=ezv7n72W^@|L-2HlciFs~eptc3r93BHMldDlb9-j zs)I7X0Pgkp#gC|WoCp>*61S}ZUZBAgCwv6V)BQCs%1!CMF7?#M=?6{=hiE2lTN5TB z4)KHRA|=$*>`OMS6yHF3$iwC~g};2XdsPYYu3fSk_7*OMR8Y$nq}9-nXJs={HI&u1 zVuq<0Z0-6~LTyJb#&ln~YamzZlsC`FM(LwkI&E0#@ZSD$SGrupA6y?=;06&vfotj# zn*gGEOjwBz4aSjDKU6oxx3s(-d&=xP{0qYom|dwm6V3V z56z#a3tImZdSQs=#`f|J*as>-*v42Yz@Zfy)3$HNJCFjFFf<45?HZVDXoz$p3BeMa zKkahP?GzZ=e%NrDQj3xfQ7u;FZ$2&sdWP^@W)$V)>yY7(EN16(OimmwSiU!`qPD=; zBhvVR&T)1ZURjp1Lr&{3Zpo&Gjk&RnI2;qqP<~-|1B}KQt-le!H03M-=2+wr-%N4do?O&I_ppKwLeW^b?H@sW@Ir+)Ilg!|e364Kc zBVYcqTJ`U_N3xY38r3)Q!N33P)3oCc%PVZ7lafB`>ipmT|MUM}UZCC2_x$5eDL^Ii z=@yt|nj=vJoz59Xq+k6bn5LtU$}zvX&kXtPj8GT|xZV|YGshGala4!x0zkT3j>^;- zsXY1{u$pyjPgGRxvqkrll`b-t3{9{C0R+6k*-a9LymD;~`dz56E7TQQSV%I@Jr$#I z0Z0DWHiO5uH+XF8;IVB7kgO&~Q>zCIv5qqtkq!m}P&;{#UZ|bC({2z->n~h0By&?u z?$kN9pxERTmYnj^bYtTO>c`0`ZnSKnD2j6x1cBOGo^We*<3h*LWsYQTRss#+Ro!{y zd~`^So4o)Dt8vBf+hc-$9fHOvci1lIBGwf83n7P-L|O(pNF{^)BVBEsB~FwTF`Otn z?ZF_UimSutatZHN;;-|#5AK*wI#5*s#i^vhBB;53-Xpg;A$q`P4wtton=YW6w}Lj2JT&a9!d)&X|=h zv-s*vAVJJ5Mc8ttN*E<-9EKqTa*P+tY~oNc5{P}p`*mzH&}rwxlm0-d|X4I;^oE-?WC)EMzs(easa z_HGmb^EL{UG#NrOBT7cK_#bqruS=N>Zya?o-XwU~Q%5+&J$HOrQsg&#T89E{E~=rC zUXeae4-}_qw>(K^v+MM_?S;seDp1K0tghYe5nxb@RbX))+n@uG1m-9-2~~I!TRZw2 znCMN|te>~Ld;Dl?jgal54yU2MOWI+qbfh1-QK68=RXF144|~&oJj9G_Ei-?FxqFEo z81#^Mv^xWT1IOUdysAlEv_1@1fW)M*41L%Obmi#i(x^QgHHZ{>1hy~N;POTufFI3P z-%p!_HwMTiaD|0r@Ai@FqMKzZdqyiwM=h<=n|59_S|w^y^(IB#AWLccN`KILS5sa; z*Xu53ij~amz**2~JdB=uDXzAE3OHiGqbz?S)-x`&-z%udB^rAMYZ* zw|2?J5XWZ!Aj_=W;ETS22&+ZK?N0cCpsAj^wT$ntUJI>TjS5RtI2u z=&fSElA2;!GOZ=_#s>9N(eQuM&alG{>`PEr7sCM}dqqoMEpL^a@MxU(*NrXm%xtFX z?NgLsgP?jUw>kZo`00l8MR70sa@TAoaeV8^?NMUxDwJGBHXlm1r4F!x?hy+l*)1@G zz7I30`5~8Tb@A|eGyhQqd1>u!On=ddV;rxReo^MgG{sIf?FqF_OWzoUBXD>}EVp}M z5^4r`8-KEd96`?r=Aird^hLKgjjh8ZnSx7lgMQrO{&_PqlJuT6SMd~u)WcS@>ON(# zjfT_1VA2Zvv>XvWFccuPu(z&k#q2VtLtDpb6QB)GY~zcYS{-5eweIA~sa>9>B%GOY zl@V(SO+g{;Zg|lR>Rp+GneOHW>C2-q2aQ}bLlE@VGjbDI>@%N-|BxctZQ|aQH#9M#^JC4ut~Zos1K~NV z#IC6-V+C3DH#&bJxRm#L_T`zPB=`NX3URzOwrQH+RAzwSL~!Uw1W}4Uc@N*1{r{0x zWcT0Hb;EJSvZY{V8!8A_VDV7dtxGryN;&o-Njf?0VZvJkXBB=j)8CfGsvqxy5Jk=J z`+R`6#eY8)ua}w#(ncl7fXWk_*}MlH@y6JWKsVvND$p}(Y)1Brnzo@<{%{~bz-{&h zV0Dqw>M~$48{SYJzHC z*D-X{LwRr{OFX7-Gxl`TZLWTHn~|`MM%W|r9qIof=8Uk|BrQ(}A4!^FsDw_E99h~} z)^P1Y|2UFs(BL$9Gxl8*AulxaUE9tB@fDb{@DFTT z>=aHEP8Np zo#x;Pe`KU0>Qa|lsDQkrBoQ(4AsZ69Vw%43bN<$4=_}#%#^MnppD{_FDn9-~y7Pw^ zX?}>eIJu7xesBLn{P$?9Mp`PA_*vXtdG_q^clQD6!{hBWbDv3fpDeTV;UuBCXV2fx zi`^mS-NQkvtc*7)orvoG`)6A}{vq)+arB$&rP&+PbBhlS|K!pql5XibPDHJ7;*o&( zEBD7V(?l#kqGj6yw0CJVn`J3q{uYLq*mydHdE!y)!BJm}_wsCyvzeihe<#O}_YZMa zO)9M8UA)(B!VibPlPg&MY>8a;ZKtx;rahLqkTD*FfxkbQ@#g z%?!9nmt#T#g0&rJA~hmu#UtVyO}x8K)^EcGf(@iqG4FIB`GlicihBJzoC z7ryQy6K=_7y+JoOd+3=pfR(XS8j>tX)h*2LsuCcj0Ugt11cN61XsEDsS}*I#uT3w> zrz0==+gC%u`i**|%xbMV#`VcBdY?1h(_j@*zKXeiwH?`*?bw{=mZ4p^{-f3rlktVEAaFG|%kuWm=Cf zul4xS*5k{zAgO`o*lIWm%szeV+KYE=?(S^)KD$#;p~v?;@BXy&^Lx>kQhsl5gR2$l zD9&Bgb?eDqP>UY9B;V2%S?CIF8Rp5_MlTU&onHw><~rkfj8Mz*svmP{Jqv2#=uu3m z@|*Z;3#iI--p&)LA~D~&RovI?)29Ol1GL3spYAUwiBs?HJ9mmy{@h; znbh_6w$ zci%Cx7kRz4$TSEwO#RPW^x^^q1Q5T41Xvb0(m)kj3&CwHwbFs*JuzFj_xP;5WVx~i z-*|Z`w%`II81u;ySRsnaqOM(`f8Z|odg>nCe#RNo@?1K0t_=aOJC`Ja01ZFh;zQ`t zI@bo}G@ZzH!4!y>`fDfWyJ7mJ=Q05eOJ34X8rh}U!uJNxR}J!^OHd9`pa#gAVk>=h z6pq57zGx;k9kJ}@un8-9HN5vD53@_>Gg!HdH1poLwq4JhihTXz4XyJjUeA(&EnG4~ zyR9%?6|n53IJN~+ZgZmP>*D!VN2K#^S}4m5({6v<-d7H173i7`7q7iE*Xf)HakxPjR$IzdSkB%Zjr^CX`F(XOP-}w@<%dqO$ zHoTRUW7TZrq`B0Is5b^+8oU1Xy6rhRe9FE3D*&&FFbhEbO0W25j2Xu%D(zlMLKT5( z%F^3j_uxNwh0DR!F!rXdBf(zgq+9x82KWNkCDeuhR8yBZhT|VqF(;Z-Fd*4ylNn5) z1?@H@o>3J;Ned~Cf{U67*`V5{1O@QU%1F`tz*qoy)fk<18M2LI9 zQ~%`b{#H)7*XGD zMVqeXbejo~fS-u`ybkW}MGI$c^)e!%@TNiZ)du}laUk56s+Ykp!-4=$v3hY}#jz^B z7a=iE9&4KHUwMPLd>G5;2@QBO*#ZOlwyHJbODpfXKy`Y+lum*Yh9c)D4{iMFmdO*7!-d5qmkYFSVZ6FFvtD=2!R-`je4RZDoae&WAk1i- z@gZScLySGIO`I%dw?(tz%ul8JUf{l(3~bBNM@=P+SsXg54(}b^shtQLg2h!$_BY)# zSAdhW+wIf_#462dZb;hGSRz<`stz{D2&zW3OcGDuIHfL6;ImEzeV(>;iC^!T13flK zU}(wSzEdpRSmWT8}f z04?;phB^t_R43tr1WY8Lt2k& z8gyK}h?c@8na3+m8lr%HrutA10bl`(j0z59xz61Alf_yR8Hxy*D?O%6fW0Zj?m!oS!R7{XkYS%L25k? zrdYIXUlE=JJDT~FReUCG`YoFQFGVa?uutHc0XIo zkFGrdU4pSO@A!XeFnbP>Z3CMlrS^|WR};;bvP4^IfBvR4anC+$YK4k2pCNU-&eSyq z;NmH@(k-%q_S%WGVJ~_*nm#TTB{{>l+7yB#GKOtU={JA_u)AUPJ3ShD0lnx3`$KYb zq8_%mMk|ddVJ`H_kfr)DbaF>`qY{PBEEf||YL1$=E{Unx-n2T|}oFQ?Zg-E;)^C*^RA@GH!9iJMowC zU(odcEDfuyU$*>|Ysw_Ru-y0cnVpdpG}uI7QDAa))1EWPyr>wOv^Bb>MaO-G7_gsh;9OhCKxXDZ0)K=&?Z@YSGKrqVo|_(avXWFSMYdOkg+ z5vx0yE}ZR0ZI<}P@%~LZLdc%J@UqbjH_s26w!l!_^|Wj--Lg5iW1{}-$RXD$8t5%b zzW2yR!Id|_856asJf2v%jNu)r|C(cErS1P@UXR0A$=2EBX=>Kf{tp*mZ#n= z00;pC^v%-uaAzDbXGnDDmyPgdUQf$d)?>}owF<1k2>2tpiggQXpScT+dkCnTn&9Bz zk}fBY`m3efLazMlog8eaasu1q_ZuxKj)23o^MBpsZ&f;ymUzH5{yXsB!pt*Jz5F_n}9U4Te%e>p|mE5O@I z5qP1BG)q6Cg%dW5YP-_-ua)qD^4DP*3M%-@NhiB;n-Zz=+wf02zs@!+dVOj9_aj7m)478>PDTl;=%F^Sw;!^O<27 zKqBrd$V?p_WJGLFuyi-58r@FVrRa;et^9QRS#d>(I!cJV2|HS8VB6sJli+2UBxc${(Nb zU$;-s8ycM?}#Og)H-Gu(Rj{|C_E;&OQ?08>RgI=sYi>K(T@aXRj4ncnGfv9CEY#&C`%(`(y zC^gsMHe*m11_3I%CIg>=N=4<&H%O;GqA4=g`Jq_!za%NLgw|sxrQ&{ulXL42r(5&m z6<>RO__#9cjXeOUvIqp~vbV-){q8TejxGY+(ZA#2I}Dk|!b(OQO8LHJd~Oi~r*f8G z`2itt7@8v5xzM~Qrj|l-3NiWI^xNZk!MGhjBbtP7ayL{0WGjPV6E^!d4p@vTQ<joyAj*XJl(pBh)jIDo~aQt?ye%#haqQ|nJ~OkZXNb3G;^{yJgT;L zoAb2*rvlB3ST@$_wJ{4>ULo$Xq8GnZZEY1jrB#*dNY>-2lsnVdK|Vsm*+$Nrn09Jp zSsC=djQPTPsK`b^3)m&|h=IP0B86g6h8pFh(G2VM`T}?^qhY`t?1ephoH7FA2COh3 z<$o9+@9F>*=f`C2FWWygDBC$hd7X6}GmBL0T+@e(g5gY!o)l+p$3}d*Ij#aZ#?k zFzzFj&JMMuoZ@b!(D<=$tHAezD66d}fl(0+`4<~TlhszoAh_6lFlGl5ZaxWpp-?Ljtr1;?66Z2}nZF2of6y+$7!x3vVr`Rnn+(p@xyh>P5a*U@)1HSW;Pl339BNcx zB#JaD&%?o%oNh;YkmLKE%$L*lV19-nER(^vlVV|s)J^Mo=K5`C>OfEly>Y!X0LDdpdj+k!bK>c53H}j|H-Epi9vlrtai2kn6~s?|m<2XyzC~kSf}j%SA8;dKgF!QlyR>f1 zIK@b8Xcn9?(kPHXO7WO%%!fx5_7rV(wcTdIdYM0zW5?@_`{)n}ds+vzgr$O?q! zG1k=24SvqQc>*6c_p{kP6g;(3UJ>s3EVUp1x~N z4>+9~i;EW3XgZpsn+c2oTR6lLzlB4X@ah{oVlnkCIddv%Nz*zYm;CWfndiTh;tO@l zr`2;!c7-^nuUxTUy-o|y1fgSrjpgNdx>E`NK-iRMUPS5+Wfe(KAo{c83B|HQ$Gx~g z)XU2kzQp(gtbTLC~ zuqlti0KNrNFxn4*<*}JQZgf08!uuHi-N#@4w0`nbCvbhrx;>Zn- z*EjG!p%VS*npCdmG%zx=@QP91P^7227cMnBw+j`hl(t=!8jVB1!If!qH&Re&&v;y} zJRE}CiMlj>@D>w_vb3|!83t+k?pSbdYYBo$GNrR<=QbN?UTs)h_+*AWeE|*A%bF5- zyHNHBU3!nkiTf5L$Z-sayXA)|&5jNjP}$5}@-Rz!Ssn^bH6}wN84{#uyGILxtjM}g zMq>7iEy?m8594sz4=RUskXK{)eAFuXb$4iX&@n>;|H}fPRc0~_$dXtCJJDE38Z>aj zITcn$Q`68ex*?UPr+})!@$;>Bz324aPRSp@^J1YLUC)l`=qD<0bG50)QKPRcs01v)al^_!hQO_Xvf^u*p-3>VIDn#ya(T0Ep857IW$%?U5(^ z3RVycK%Coo~H&rtLYSJQ_xoGvYA2tk!s2_TyfG>m?&`i52%#S%(oM%VS^w(nQ?AbSL85>)+l%o_%2dW;05^Kcs0F#qN7yh4TkXV7St=REDH< zF3~vq>GSYPg7n5ioeJ!APc62nh+VVouxu~4*DDht$#_3mE%CRH0JX( z8FlJoE4?=Ba2bzsQLRcQlS(->e5#s|*aUo5dTMGWm8CH{i(tE1uhki_04n9svrkK+ zF?oEUSgPdZdt}+>f16c{ABgK?y z6Gc$M=%{2Bk0cdpt!}*!d%Ji&E!p-ap({KJ;PxKqYu3|c7f*_b9p+w8Xbi{A$dPD# z4QECjH`r65!Odm7ZT7QCEQ@zIH-p&W`6dQtyL7V+0B>cDuA0AM8%H3bD+ocMabx_10|Pi(9|J!IM`H;0njS1%0=r zjs({}Yb|$H3)ku>Vtq%g4wf(ue!+O-7+(H(QwJvwJ9$)vxYVe(HKM=hlAY`1Id8FN(v&)hJFBXADL5 z*j6%C^hTd6MU6eW^tXGaNU#}wtB3+`3!*c2zr`mNv|tjIEQB%t%eAARB3%{29&!k@ zdT{#dWX1qXFcQULhPJE2EVH&7gi#0%1J}N5_`2a5Su+q-ti&E#LKZIG`5C>B(i5Yf zG3!ot%Zi^R{iAark{R-L{u;T_?r)DS$OKEfz`?BX{p1$g}8osOLk8yC}J1Zc<5 z3~RodCY;LDYl&oYjRSQ%tmTWO z`PPsPli3dxpg299?c4X6a%Xyn)rv$jDs-0i)R~}bIV}izmXIPJ@z)!?48W+!jo691 zA;f(oI--%9+uAG8F!M8$N7M3)dUGKoA_OFuhkD#DGU6VDL{XWUK0fTz$G7DBXR&X( zZvTcIrAifiU@p4{XCR6qLLHxZ%B~_Bbt3oNzjrw+-Z<`l1{nFZOya3LBg;g z@aZx-M5QaEOl@p%$zvE5#zU5^p~`@HFaj@sYcG?v@6ygFZ5f81^IsDLHdTm_($8DL z)-Ov!Y@CNIpuZgS9B(;5d!0RU?5cYmWi+C8r|i;s;ztK;pNM)zX_dOZnRN&>N3{o! z`Z967RExjYu5`hVQpE<=pyPIz_c2h?P-fT7&11hFf)}h{xkl^MEXOr=&!Q2I!~G(3 z6XwR6LuU+iK_V@p zG>{Sj%?d+5j#+&U6lhEmRtgh$Nbt6xc>Os9V&X7jbu0Vw5gRYJPp|mgsq30d@8^9o z)jOnUZsFA1$kR-zA4OKbh>R|BF3z^=su7<~EPh zWG+o(`Vtl1^76=Ra$<;gsL0 z4{qGi*w#-l9<8#WYW6uGh-ios!Z#5P#3ZxUmR1_p_?4Ba2Ko1GY2P%M@b$z{c}{pS zmenP@yFE>?nQWl-eVcBo$~?shKr*&C;Z$1;ZJnSF-NLzMv#pWdQX{sMT*49&y(K0- z0cjG?cQQ>ycYFvTCiP9|qFSoQ7bmLv8(Nqz2Lm$q3mr}4&hd&)*S@VG+y2)1s2eNa z(es)XRL5VAd@)voL0lTlZZP6FL36{jH?Go~?WUooc}+LrYQiT?!*pg~9!^(__oFi1 zpTNN&C=wvnM!g5tweROTw~cO^{n3GqwKNRJ2vGPeG!y%D0W0oGLfWN<31)ZFuuCAq zL`|NQhKnaF(|tY1MaQt6_xfMv)@1>0(&??vZ*R8f>MGjabiO3WUCa?uO!1 zKTU0Y)0%h|1UvXVweK3yX!UxT4otkfxM?yLCsxGq2A^IMO(FjJoMh#p%`|0-nR}g? z>CPOlcecaZ7Y;X*r*A49=k>bT49|=tOo)`;sS^aJ_f5UdCi}lOFl=mkt0_zRK)YO1 zB8~nu9IL=Dbv->$NYC3(onjIfx_O1ZOT89}w52Qp0(g@wd#^9|NWMo_K}qS;bmY!G z*wG#U;|R&5N2>-ll#*qvBvqroHLx-=FmyYE<@WE%b*1daGA>5!?dB%Pn_#V?jr~{j z8(9!k6ymXq@`3hxCa@~v0cl@UBYep2WML9_qWSZ5($+C@R6%D_VKjSJTg|e&yvWyY zPWcrH07SFO-d02ytYu$8ub#l{IB9wuq1rlL23ga&77 zPMU1(s(kY}xFJRfGZ2K>Y!WNK)z9`~rH-$ly9|>tZ*v9ofx;2O6YOHAG&4M5P+KA9 z3`c;S^l+9u4oDn07*=MYUB56?#q4`1;y5bV-eq zWr4Qc(vry5;qNw?@U8r~)oMQZZ3@PthLYn?wE1>XEh6l#3WnZow|=p;0Fw_fs-waw z2PhyxTZ;W=UHl~5lRg%%*Bb)%9-9f-!(|*ZBEtf=Lj{KN3p8C?mPvv*BP!9LbJdNj z;LL?`lHRvzW$0+A%^@vuh zQ7MOao8jJW=%}&FW$1DvV7QPR6JbBa1%X)FyxJb_izY#Ox%r|;rrmfItojU7m4WA% zbYEYpe0aB6Bi=811tV`lK60?1=qO^BB=ET=u~K-<3gXzn)nt6-U(aFR`ZSJy8#t&t zIE!z5L6<-Ny|U{jNoaD!lP4r3!ROK{fQn5s(Gul1=05tRl z+D{nJi~&3J*1&pddDJ#^w#J9xUG>2MS{rAO;{wMvyrfp??>zn8FMJkNa_h!VrV`c=dC_X_=^9S2JR3+(cgKxdhoMs!yEeID~}=2 zckM7q94BZ^ulhc04Z+)Pggfs0K^a$g5o|}aR$u@)kn&xo$)2qZ2f@^3J-aiHla1R? z-h_Op!ok$O<%6%JTpUc&?0WCHxD0r}1SS_aN*B5!8 z^4hLL1;R!&dEra(W73UFlW~`!%dT}csfBPlFkx;_x6^L7PL5kLdB}&(n(+fCX;1jp zub)3^CxffvtUKMfZrGcLAivV~)hrb;~WEhB{hI_E}yVbw+uTli=!fSu+qii$0f-2T< zQ$>ghH#T|*_<|Jyh#oobdn2CZO>`xp+I@8nOFyyK)EnPd4sc&L^BLSS@TT@nf0pw} z^j~UM{+tbuSMFOIxeln@lsCCP9RS=seW`cu?*4Pj2kCAe?m@8jyBUIm+@=B5^x#lN z}#ie@Jsn4ecnzo_LsO+p(tdpHim z(Ij=Z3>THI+IR+ovW=+0u>u0sg(L|AlNsf4w~7cYwK%xBF>~8=E4#~l+2fTG?HYxS z6M?pEbh)u5cwCJqBhybuoAtYzk5bkeeJh_o~@RM8S-N zQI(DYVO+LFhD`;sd>{oVg}pK1ixPPl^c+#uUXv@$tKg`v2z#n<+HR^x((>io9;@Sb z2_+$i!lu7ne_qqRK)dZ7_$};*qovi|CR1RNaYg^EA{<}#(1I$h)J>;=an}ozlCU*! z{iit~kY>sFt|eVrrfRFOFBVBwKzFPI1@hIU{gV|rA&@GA(dum@2ZCm)I(_CsOtaZ? zt2ZvRL%$RwGt!w1u-hq`q(u+Ux{V=yQf!)TZp%!jRyS8jNq|G_iz1ee&?is-;0JQl zBv>^}n~=C-D(bq*Hw0|gt|hT95S)n=d>`e8Cx(*Y#LX%Yc9w}ohfXB2WqkR@C86thfPKyTrchsVD(wg;Udo8%dCc~T{Y2CcY zx8Yp2#>V7it!XH(cCUMbN;l8dH%X;S|-~w^SMRdIgtbO4J z5|01{=0d~IuZjZKa3P@iKOMFy9vBtO1d?dz#>w+CYOf53aCN*czY+*}P5$IAxbZQd z(K?$K8f^o=ge54tAUWdFUh2zxPdN|L(r~y58}ff2mL)E)l-PO1>vx77jj{f(!7w#b zxVwTg07-#4ofxJhwi2B`2veQ7!UG4+-hWFm(T`p^{M-9I01F@hLtU#i8L;6T8VQ6K z$usak7^_jvhN-R7fuV_mYv#`y!omad*fw7p#sVXhPRa`<Vp&|{s$jqz|xI35wi?2Wjsv!y!hsQCK7&f_Xj?8;byspYzC^x`v zL9B^9mviU7zRp;ZOuzI3u^E@9L>wQqTD-NeGbU48d~4B<#^)+gA}V+zs8Yan0D?wz z+gu$PtZ2Ip!;mpEBL8fkA~U0e*Y<;88rL_sl#AgEw~ELqLl6sYRCJ+A6srhz_h+U; z2yciOhR7of`fN?`Noh;fWJE?*7#eDvKX7F8Wz*iLTHq=J@pX|<6nILw!hdSvutLjz z#@bQ?K$4eNnbA?r-Ea=44J32&PMqX;JjicSs)quaim05pdB(BtM+5NiwWEVfY%{=}@3@39Mqf&-CJQ=CL-6^FFHua zBWJXihh0(hM4sij-oQWO>TJ1F-JobYEs7#bZs;~ockjM;PAy<*fw#AtO+RZrs4^lG z#5nImxc5(OSa_vh8gJ}dVTaM$PN$TwnRB{`l!Dys3?xDcOzY01p-3jY@wo`ny0MKA zMT79ro6z|NQqS%ZAsAFtTXr?PZh{erqk?uF<9(LSi>N8HL1C7tUZaQc5{;-QgTIIR z*z>Q?o+gDwNO%+55wJfCf&R%_T})vwEqiVDH>Q znq`RvkdyxWM;C6r?VYbUb>3}f3=kB;)J(rps5X-E`C#^z)ms58}eG#gc-QidcgH5!A3Shk7 zyTnL7OSCSdx3ottygBZK!ZtC$2LkB3Lh&qG$+S7;OUM)#r%WEKI|ax#Wgv zFVbUq?MlJJkKP%7w@?;?zU-DRU|A28 zArWAP$w-Y16L+i)A1DbyQ~?V|;7?U~%!pLVuwGwUaV^#9aVEeLBwu2g_3EZn#pc|O zdXrrC)qk)ULC?l5qb&BRc$S?Y`@swId)_|12z&nhfAZs6*rM zd@di;i*z7Ynz^0iWwK{Cjwb8;O7>OW(m>3E&!aKi%&TWgcwp8INCPIqyPtF?OH8qNiyPSe5>h|8{Sy*fWoZ;Qf*L zgQxVoT70}tRUhN)*uqg+;|f%ZJr95vUtJys}a zflW7DymzG8$xmkxMzb?r8 z%g)&x*f4{xkk>dG3|qz$?{C0&U#GRb5Q|*6XTKk=fot2* z6Qt*^=_cw^C#eMsDZ8TqZ-h3ERC^Dafz943;8$OgR;?zbr)LeWGh;oLD@hiKI8#S@ zdZ~8Y)XQ6_*xVtphH%3w8_mKXKMZkCaJw`}cUv z;#8wIO2XXXx}rM{YT>Mn$xXji=DUvqfH=;Ar-0I6>F1+xEu1*aOn+-sX2s4bBxmRL z;8*8+`?yS}r^paHW=}%P-fi_2q&v#%bZDn$tIam}=e8r%(F^{$>o>uMuqak)wD34Wj;$YYs*q@#8D#Y6evPoPgWF5gjtI-2U0cY8P{&ruq?BU z?!_HHhHmXcxN8uwCI0DNoNt*Lj|a8nTSs2@lr|9xlHsT~wUr;Obg&|99yCy;Nf^Ud zXF{I27;C3Jsr?FTH&8bIMPwEuhl8=uOw>3I=EV{0qT3&2P`!cD>!Q=MM$a+MYXeN8 z>Zk6d) z${(DhwtcgU?#_{EFUNZ2x1v`+T_@zX7=bhO#^4Kn54`UW(Zp*GZ$0dDL(80k~k%!h=qVHPJJ^ zwk`;rB#Khkm(A_%9rwK!ZH>j`N`z(dxqzN`%43*R9G@|4qvQ8Zdh|9nNCtRDK8y?K zc1(eUoKRc4ei^Of`MFd{h@j2b?QZu@9p0v4xgkQk9g2#b*x;I)Chk*tRq&X9TD=w1@OdSdNo1a#gj=F5|5$redv;*hT z&Y^ZFtWT1m$l*z7Yj0!KIhj+GZ#B5s9>;(ZR0a^|0g}cuY5$cdtG}2|e2szB#z{Li zzV1Q}QFaElKr~^JOU5G2_66#pY9f3>34 zdtwsG$4tPBWRfB2OY-FE^p^K^j6ML)=&~Qz;k0`n46(CQvR7@|4-UqSNpc0V6M|qk zC8Oq&?Bx>@h&2{=yj@;E(P*LqZN+Oj%|@jz%iy7`5cy({9$sTWiZr}lCTj$q2--yE zCol#Uz6kJDkY_ks0|}opS?v1|qweRCXoEUfOX=nIxolg`@8EshzKmOx9Q9hxlSWML zPYy+CRV~w(jUWu;R)0p*|EBO7cw*Rw7#J9Te58sYSayrAz;GGC?rXpc69C6ybe3ky z>qCs@M4*BSn{37-4TxV3*+yT|ErN83#_@`5Li`Tw)`^H`UST+=N|8BQZ!*Jbyq4zgG(rpB_i<3e$Cq>3eHO!GRgxE$;;m4TxmYWPpX?)-i z!D)C4uDQzUiglQ67;#gsSkZFKxUkf4?S6(K7b(RAltbo-kpkSlGHQ@F7hmaHS}bwMN(!&u&_TY`e(jO3Rtr|1orTK?_ufX66;)9kqWB_Iy# z#^c!!C4QH3sqN@H^uA%to=(fF#srrIw=5IOd3`DpNM*pTMN0&j@1x@K9*+zUeVkCiBN?=vQ52NyhVx?-yHYK<+NC_@t{%TzvUo@`& z(xZzP4lpJ6<;~-f>EM?PY_=O_b5>|~SbD?@!&1}mGf}Y^{bp~U9uV}5n){K05=d|v z6x0iJMX0RukLAo==}OXbEEB`I*gq+H6RV3}ck& zLu+YlIg4k_2Fpl9WF@@i?DVC*pa8j3gim+;d}CwU4RlSF`LqIEL)*TN(_@(@qFv=W z*n>0tIIDdK(q!vn+Ym6@RletkX%owC@2Dhg9zTaw=%HTNA1VI}7;JbJ+aj+^D=<^j zofA9Ak4H!*d@R$b9XK*w33{coj(uSXUG4~x3#VhTtZ7_nYs8507wuM^5O)q~iXG$X z2G}pKpKe5 z#~VTUJYETxyRYXx1Y8xxQ;+WQTrCmIv*M zLtxoSw|1g-POSV2Hr6xIhrZW%YqY7q$|vr8Y72zNp#s@ESs-u4LhwL`m2$6Ftg{*a zMCamxha77k?uai9Evq5f{7e^g%X#d33X-6gOeESwvSPPC5YW&76~Ut5Ui z{el&ugaU}*POc2Q;w`)YMn5zmYhoRC+8V?9>=mSP8I+f!J^_X81uZ4RO)`ys4~CG= z;m9(7cq5n~Bj|u{$i3ypI%75L$lLv&+Zs*h@V;T4LVXUgA8?d1AuMV(MZcI7wBRr- z^%*iV+5E`*h%;yEb!);-zrHe{zCN~V#N5iMPMv)W*hyzk|8$Fhd3+w9&A-x-k6*gT zrRn^|>2%)^0~e!i-^Hi+`D%dv58%^eoSxmZAPlCPvqFH@z-0R6AsJ-B`_~%bZkazA zj-f}N{bTK7s$gZ=X##`I_#yH0x4m?dn*>u9vC1m!&y76GJmWzeF5*%Ak+A3FS3wB)76!&KH7wJU4=HSLm zGm4S~Lx+Z3K$Yn9lmXA`OE{Pq%>owsiaohRBSkYn0BxZgy%)u!XxIRs=)51h2pXaR zX;zK|=@{(uX-6+p{lFiC<6A>eu+D2}_h={+$RlBx7^;sutx&2F9tX-n5KRvFq(1=~ zQ;?ym8OxAdG^8U&x=Ieq71_F{Vvfg?h}pJ)Kf=6Iq*BgPsbUPf@{Cn;W+m!kJ7uEU zpG?8Y>B%TbqKNmkRA-d7D*bIm7Tq|9VG!IQUkXH0*-|}sX>onzPIE{qB1?PYO5j=y zb5V{@NpW*HQLa2_ag!8H?XZ;Z45}73PVf3aLUCCnmnRr#vR>QdE2@S68yj2j z&#JIvXjvJ7WI7Y`%ekR&Law-aE~rMSXH(g7!&oZ?LVQK{oYBptdHg}T1Ym4FoR%C> zYc|{;(<(9HwDPHy53(UYojstc`>-rEw}wr#R;%csC_IWzB@k58%}E~U=x-XL4jo5P z_*@Ro2X?xRR?C|G(ouzG@!}e?tRQn{1(@TB0FZmS^iXvG!iYcU_q$Tq-cQKML&!NG zb#1Luamb%uUYYjP3~&A0e4(Z+FPAs~9EYdU2I!J{=K8*DAio#5c6D5gf(TU`nccG& zn%>Ov@|5Os0YE&1-0Z+lWA8%E>)^#J=N#phZ)m`en$!%{`u2T+yz$2Q2N{yX+AGy` zhhUR7@SCi(oP`++Lb=Yn$nBtZ5(q>+);&$q*+~w&SG0YtIj`6L7iuj0h>I7C|5k|d zyvMaE9@$>BFOfWWWcm}+U&zkb;68U_jQ}xDi|QrHdHQGyut317P05uP<}iHw_KOdtg9@L?Zfe4u>xiz(~ip)hJc zC__~b>5VwCI;eE%3~6ZznK}y!`p%UX0Bn8og4W{xOU-c&Ct9)0_alMM7xnfIYM*Ns zJvCv?`BZ&nc*pXJVMq@MH2`W!3KVYIohIi1RggAl@;l%DESJN?V$-l4c>m3QlVObxj3*U{h zV>6W};CdrPi00l5xeam- z@+8-tm8mA|uH>x@e!1qZOwkWx-{@qxXiPi&PWq(c4Y%Xm+@}152t;?+j zPY8t5Z)C|)*V`nj@b6G>%fi>0-SMz?*pmBSDX^wq&*#6 zukQV6Zxb=PX^k1Eq^~Z(RrFFok$}u7cXR{ac_aWkm-}K!u(8e&)g(K>7B`x_^^r0c z>+>632_noLzF%YvD6yo6Ze@!NY!VkGed`C0l#HRYRx5f?2L*z0ySSHR)iZb}yy4$_ z8dvXcufOm;)<=&7YVcq=h^O_T#{ykn`R)Gwb9+U@R$f`t)_QF(+wZLt4cS*V!mKmB zb_JqbLQ@iS-OmSCC>Z3vPSc94aqFJIp%oQ%(apOMlKc*hR3eszsM)arpaEK0f0#Cs1j4GEQTsq^$$UleO z>r6COWt2(@gv)MxInrmx`wa&AXUJ32FDyh`qNXT){4%ye6I>1XN$Hd(wU0ujux_Kw zVv0%WO-t%RMe&T$3nsFvqL{16Lc@cZ(zNyY-P16tPg^eIc%D_eD zV#_dgrP9_v#M>rQU2Z}|?ZH&Id77bv9(O8m7bd-ff^P9cR5W8zO{x`Yqi2!h%5EEY z{Tfe{I$@uz-S5Sc89kE66DqT(I1;3XZ*pDE5H`V++54Rm5iO%cUBo#e(j^p)WkL%? zL`yNn6yb@ZyMvKglPwR4w{$V5xpZO)J0cLlr9e-j(jBL*^GciG8_DJ68eN-Pn?4o> zfve2nDJ?ygwhst|^e4W_s;YBy+TQZ>mM4_W_ zXSNAz9_kDM6yR%&4)ki><;Or(=5gW1FnV3e2xDiXt{vr?K^A*x`U!!_HH{s@(JTZH z_t)|H;kJUsve!{g-|H-6mOE!ul-MNID3(z9e^hSxKukE z6Ar5!nKVMV=dg!n&8Vr{2>4m;B05Pmv;7;1*RjN>C`#{n7OovQ+8lMDNYNjJ5M{qy z17to~7laT$+uMUf&c z*@J9tbqERBb{uznoYVj7HF$s!&P4lVY%u>Gs`ruqc_%i$fSPw49k);0p~MSgOxb}R zE4p`_f&v8n%zV19>NoXPuxh>YKWpgmT}(vNqjJY~3tOOO0uAAgR7Su9?k3Js%})rr12Q2t1V0Jwp*b;*k575LAUDY3fY)DCT% ztLeQw=WG4LGEvP@U6vheL-Mc&3#k*}dNcc%X`02`I!h41m?fFni5_jNjOTmcmOl8s z{z~bjFwVvFVI8ITZbvg=hAVPPG@_{ob9HJf31*nN2eUqsSn`FUGmP@VtdCf6DS${a z3+hANRGJoz>;$)NlI**^biUW)em+`VR}>lrHJClc72C)vL?+^Bkjte&6JT*bxjN$} z29@&gm)>3nEA+l-g}Y5q@D_Ry3(jW4k99XVbmj=3O#Sk4w?yxqz-ZXg&v|JvTu&rg zcXn>Kys$1wuOZhhF~)p{HAt-9qIHBbgxAW{F2kRi_eu0tCmc4bFQ!PuG#9;Z`KcG7cZPFVX(+alR9Q5UmjGAU1l>^hNZ-8HRfVJ51$< z7fniEx=5v?rr5sAvr2qH7l$4EA;+d6wZrvi0+V=``%!BQn6*{P%x|J#bC?PuEND|D!(f2m~Rl77C zeeXh0yVS^xzNIg}%*}0Qw5)E~xCv2TuINbc4H$cgkNI%VNLSQ>y^?!IH5G1Esiq9T zsBWS!Ra!4aHDwWe13o^jgIQPp5T**Mno>kfS-6pUBxb8hHD&QSoQeiPTEE$XcdNYu za~{Ujr7{X0f)iKzk$FA&(*w*z>&Y*XxNd-?IYIKfg_pnuj9`bK%iWa0dEPHX4aPD; zYd6MocG9aFFHfF`i^h9a8c6Rm|4JxnUhU4(OEN#SdMT5ogFpiC^DIfDOz%UNwU-W` zRYl;gTaK=m!NJZg+ZAo2K;79K)Xci&st(^<0y51U9>VEn8b6KodMbM#zu3dAKeUHi zbvS5~au`Ap61qRQd*1UVUsC_S0L)$$)w?TzaAho`qj$*}W4Zv;a@l(5>I1kf#i*l} zlY1CK1tgG8r&%W}A~isdz!;`3YX-o1%CNNq=(~(D4FS3rnPun0AL7as!818NULkOu zjtI`kAL2Yo0Pkd=dvW!a+jh3j8TtePTb~b4nxPVCCyq_7f&AJ9&0#|o)es6Vu?+G- zX`|Q8?lm8^y#_2llvP7!tJn6Ai3ID6i`Rdriku%~Ehpd>JZLUN_5rOl3KSHjt> zFdg;aE-%RFlu)%=vS&7lw4q}a3U&v4z^0_S61^tbVap78!LE3 zY0L8L=G=QTPi~_Z@f~KT!y@@xEuUun&n&4c$RAP_0w?B+QEJo@x+WUsDt`5=eTF1t7p1BI&&V^)m8_SZQhTRRe{ghnZX%X>SqUrvyfypSzFF(K6?6L{32dPu729y5a1Do-+h^ck^KgPZ)<& z=WQqcNx%O)HS8iVw8UM|wVRMmX>1^iHw{Jzj7-S)27MI{E#fXPdDP0EY#t$-iiEaNvs8?NSbqkAEn82IqF8Uc{`UyWt3iRFE$I(*)p z93d}Yt2Fqg@w-3s0)Dg7^;KdzHEsbCW)wP$&9``{bg$+A?*rOQ$-4Q9UZU2Usq(x& z;SAzk_l=j0Cm0-O0!z|vH|1a$r&NLv3!|1(7>msd8Y+ivWx#&Je02gd|LdfAoA?71 zQzpC(RWb|FznReAg#xzPwKv?PoKm7LWNEQSfr*m!dD3s$voQtjYu5dWh?sC&Tv|v5 z{8z)hO+c027u(coReS?UdiSS3U!}XT_9=350a3FGarj|2W2#)S8TDEN% zhj*7Ufi6@~bkoP+%=pH_PjbSsNkir8W=%hjs!RZ(iE94NL%zeN!8K^5 zMK3jtdthZ-D=D&G9{bUA{Fi6``KRlyzGCl16xmim|5*e5;_w|?<@tu&bBbB|LYYvX z>U)4Y1ldmJr|farE`{D^RtbN(O$xI$y>d-nM)p6~vwZs0cdLzi73?7Z)yMk75u{B< z+KZ%JgANFTk(JjiVby{)5bHk*hMBY=E|4iiY+md)*GAxO-6{Xr$I41$r?uO?w>TGk zbZKI*!|9rpYVaakIX&*^-W>Tqa|%hL(O(tW8}}>9=xl$+wG)On>Qe>qfx}?3c|SnP zceT-S5`_&qyL;2^n9%3UbHrls%%PfCgG>I1~u-1cnw zj|5OL=NJLYx@{T(bP^Abxk>eUIa9oAoXku@#kyLDz6)Jl@mr1#>bGNk!&G1xvD0`; zs693w_j3X5jJ7K7MSaV8bZuj2d{dE>CM&A_D^@;w`S$g7|Nixol2fpp`>yN70ZF~) zpT#+Azyy(DzMy$=b95z~Nz-tq8JR4VCtG<5p7OcD>nYHsCOBNG}d}SUi51r&9!waHfws>G$nWB{L z|EPfx9DOf|qQtQ*FKP9ic*;gg=z9OhhJ(d6-fGw7qcSf~z^^S3?>xQSH6Twhg3pGu zWbt20jyf!}6u8raFWm*%%0PegCVM)?!{!5Sou-0v1{fxZ@)3u9oPp{Ki=V4PAUBU) zn$wwtEsk#g>LAUeN8Ryn$I}YUbcC$x=YRbFL-A)!o;xH4M6)x00|uS^lG`B?L<|62 zIr@}XG=c1YJc>uR-|ejs5KA(+GAyn>yWIS2WB<|MY+B;n3Z$hd4m0n>{lfq2*I-aa zo$S}lN9aA019zLdbaSAGzw*pwHGCti0Uo;cUXozxhD_v6WwXJl z5{7====)aZsKap*%v@#_&}-c$@Eu)Njvs@UN1Sk!jH4@aq#q|Qw^isjf16gqvnb?w zVzK>F71Q#&gB#YaYbfBqREGg*=|)#|&+xGUwXjPmmv`#%4>F_nm{xndR(rJ77Tfx> zawD8~hJjcz7desGT&Ox@>i(Oo0?Vc)tLjM#xl;auS1iB*mgz>1u)}{8+_ER)IvDJL zIp44&eI+!)#sW7#k|JK(zyTevD(l(VOY1@U7YOnR{j6;tVvoNM%r%BnIMN$3CG1Y*H}!G1RZ85&{B3RA`01dJN`j#wiv4$k;p+xLzrzy##OjID zb44z4CPBA;y~CHQC82wLl{=Wv-+Dw(o1V3#<5fz0K2&y{0xP~@xeWWIj|}Q{H<^fs z4aWUZO|aHK>vzpkO;bZF7L9#(dYaSgj}l-ZXcA$Te>7SX-2V=jUsNHo8@t=a3NY zAZ~ZC+PVyHXXep56~1YU@j2vRxA4w+tg@IWWd|>T$5=7#{h~hCRXgyIL<5;sY^fV8 z$G^+gzsK-$F{whGfo~U~-@zmo3|d@8`rfe3bU$S#1F9GZ%`Eg43a(nCId*tyvwPEL z5fr*(wr3x=16*0;{B0I9`N@4v#E46SMO}(~v^A#S!xP`V62D>SvhT7P><%FrVKvn- z7zyIO-G{3JK4oQd1%=Lv)%a#q6b!usjI`sW1N$WL(jicWTC*tn0|rM&&C@O^+I~PP z9<5wAsz=N$}2?9z#d}m?qZ;WV8k^ zz%p1LXh$bsVDS{X%HpYdT0Ywq!s+RzM+#-60m1Aw1L)Krb$4AApT}7j4m0Rjy!D2Q z4CQfutJ5-TC@jF^eVPlb99C=6VK-C_UUREl;f6iT-?bw}>HGT|l~Jo)=525Rp+0DH zZ4YW^T)a7;$+_sX0wGEaG=)mL&Z~tTKgvghi8!uGPKSys;alFe$WciWQ@;=b$4eCq zMWuv8rh`;^gVj(QR;N4G8Vxpud_u2far7O^tPUA_PJPG>+IP1 z{q6GNLMhBT@?x)$LIk!#yTM+~v3zn(mafaqez~@@^EaVqYO2QbzuGPKH{?oX3z51w zXCLP=>X!)=S*wUKNj5n#GP3JqjNx<$|FnPWg`sJ{`GrBd*$jd`PA*>biiBAjUf3I~ z#C9RWETRfMRj=FMV`C`5wBmbBZmtmXUvV#T6rSUXqhln2NF-`2HJu$m0LbCSZ9n+> z{r4Q!l|~mqe-&&z{g_S;cnD{{vUSR&u~h1>N&dU`0mw)M00AElp-+T;``6D}qXf`2 z?E`Ih-BAf9ho6yTSKYF7ITfp*1tK(-ltuQ3dn-FanJCKXR2d40>G{oqRgdO)Op^o@ ztFcv{q~-+Oi3d?gyWheg-4tPE8tn8`8nNmZ(+m}4HNC6U(1S`uYn&Q+QJ$)fkx#yr z@Inpf1fsP0=ID=b^>!4(Kg;F!a|zw}RuA!wFVC)79}a8G~lc;_gKwW-?x_ zyrN=8Or;7;Q&~KU$%#_6Jkc1PwPm z2?`n5zuPAt)}TbVsOD6~5GlxbhRf&c%Sjn|%n@wF1|b8hR3M|`@w(~uTG$QZ;qQQ$ zDVs@Kb-hQmQoBpSgDO#hHxiL>2Tvg29NoMtJFxtF3%!Snqf*nT7fsdK2E)B zdf}a$7)O(-He;)?8j6bvp<;GT0lv)W$Gbq7j;x0D2gFMRqCe2^GM0g2Gmj^-h#MFI zOa;yEcqesN655Nu>3vJzrR|8=3E2O@FSUyH14q{(rKqTg7w&i121B?s{RmtaOUpHY zZJL?(R4z<5yo3Iu@Bp^+%uyIKOw+QgTOOK-jXY`&#Kf{yJ`JmB#;7KQaGPqR@}pLC z1RN^Vht&sw-jp&9*fY(cAjSiEqKf{KaC(iiSRqsX$YLqRb9Q$pAg3p>3kHJ~9A`df zGndV z)2xwMP?6OV1*x$z_O~P8(iycSazi-D?=Zk$(=R32+LgfQ+-%yq4n6gab>d|j0bERK zYCvt&4=GsQTRXrU17zVU6=1Rkjwg5jS9vzUVN~*h8ui7X7#-gjQ1G!#BRGVzf4`3w z&Bef!2y7ujZU%9qffv9LW*1e*iS(t8=FF4V${BX`D(*P4tW|ahi8t9nSoDQw>cfZ{M zcBlbx;vSoF_Ih}mXIDG2u~kNz0BRvr7WKd~G-C;3ld)=1r>Lj~n`NYu-w|}^0rZ}T zn-K;t;t?)`pWVF0K8y-hbORiog}r?1tJTgPscJ1s&G(9_Z%WYRmoGjCjTZSTIXA;$ z9^H)}{?jkaV=>L7ZGi$-oK-bzcIQua?N1>tofLLD&v*8x_VQHZ>A+-0}r#Dvofy{G=Vr`{8ra zvQZPjlL3UjYVTP#P^&3xmlH zsZ@%iFi?-Lf&?J@&$pn>FwiW!MAzJQkAD#Bym$b6%pmyf(*r8nr$=dYpPoVCpc+V5 z3SC~a5isAFPd^MX@zYN!W{T^WJS~(+?fXo>WcxkW-|_vY(4&%`m-eQtPvmVW>aTa` z);}B?qzgk%BKXt@nGy3M6-TZNrFztw(P~4l1EUq7Hej|Bt9{t@;dBnSYk1wp?dF8di_7dppjhq>p(xi`Vas>Jaua+be4&(@kS#4ma7QF1uo}%1wFpc9HC<`U@rAiF zx^=;n1f9SPdWB1R2`A{2+OU80NBFDysZ`>8a7;U`LA{U4F6&p=uPJAO9OHQ!Lnhb} zlTn*tvBe>oos-CA)pEX$>th>aQGC}iR_HV1id(frSEcUIqe@j%t*8eo=PexKK57b8 zS6{IJtg`7_^cht>da|iw?~u@5Oc9e8>kmB)!{w56*x@)6sPUURQoS*h8N?HSxg-*9cx(imGc>ac{A;=({7()zW9lQSZ-3`r zs-AsKmBta+FgNAEzNMZ)85gRjlBHl4SPH~9e`Bkh)Si8_MzL9J7MsNuiA7?OSR_`d zRce)5r8b#OW|P@u^wQI9PuQN}<#uix)1sQ!bK8cN(7c{or{Z%eU1cff@=0qt@}-k$ zi&tO>Fb8-XX|4)DJ#7$E`vNpzz-Mr$XYwlz-x3{iv>Nfp z+-PHdj`CdNIE15aiMiwL8D2^GB>oZD4GiI)z?vA8M6eK{j(g)HM2Zq^xp$^Sq$F`t z(lWBlY=#u--p?B)We`|JRV}xiq;nxq7#vZfuYPSJ_vd}{yOy>NT34^^^lKnfg2CeO z1fro)vb7{Kg-Yv=qFb4#+iOpF$w9j9y%e<8394JHyIl%GFoI$MLDWxG?Il0SN~Bh@ zrsRFkW40fh4X;qD)S47XI=!u(eTrL-P6ng1TNg||yGXtQe1T9T4it8`Pl)g*cK|Lj zLz(KXmkorsIGQmkWOx5x9w;;mD;tKLgA>ce&BKe!(_sRUkCe9)WP#G1?vXRXBBEjy zdO0tbB&8_QGO}{f=~qxxs=^2Px@kaF&0Y5ZKoA%Lg~1Wo8-^^+E@%ukt~orRQAAR1 zGKFelYG$6*0ZS`u8(TYj8l6!klf~w6d3=FTB$h~Jazz!&s#ICrolT?F>Fdw4&e)Mr zmZ+$zsi)>Eub`-;4C>sERa)`OokpsruF+C>2o%;#*Wm~xN>fW)2d%58Z-Bw#@C2fv zkufQj^(2K#1L&pJo(_Y_Vsp6R>hYAv7YOfkjznThflwqa!wT$^4KRXY(XjER36i22 zRwA{M$*nmXUZGT}HCmnC*3RC+(aB(RcI)D*(;JKw&9EFVh!A1Q;@0wZ_u$bpp7osP zz2HSJd7YQN;#Fo8nuV1O!_L8p<>Kby#o-A=J`z7!Ku}0nL{v;%LQ;w%Eh8%@ub`-; ztYSb_&0U7X$s7>{OYnq9$b?Evi8-;flC_Pkojr}tV6x=l)^Xzk%C9rU5~)nCP^!r0 z^p@&T*~dOgNJ>e|$OiKsLVig^6VY9#)~h$j#Yjf_cT3Y7-X8B7+N z=QkCi)chruRQL9cwy{HC_WP!giuxQn$n(At(sg~O6ZdP&fc*$;d5?8(7?NQ&`~FybrG$rYv4!bsh8f#?<0LuRo_(CFCE3C zv;Hwa4on3Z?9?a1Xb~>RGS*HbsoP~t4Dsj6Soy?yc7a|JnkxpYsH(Bm01G7xrytWK zO0z(Rwobv$LR@eZQ^^P{?y?Y?8JQZ-hy9H~RiJ7j=VqrNU4P*wCx)_dL^nV| zw`0A66&i3RiRk)uQIS{irooSwzyqBjd`tE2m`;M&BzC3AgfWzpV=6C;DZeRNnzXtE z7ifa&5PIo`}^cHTRsW+l{)ZYnT=%%HI42qLyjldi#M@*GkV?gOzB_+J| zF&HahdBS+D2`(bH9oaJHII%r*&dJmlj$ZX4w9JeN8!P#F_dLhg#EyYlyy~1xC&YRr zkS4#;=6(2Nj5Ov8Li^8XNv3P2$+;N!@Pg!8Wl{-DWNE;XnWEQGy4rLhctR*#thqpU z0NKXw--WqeZ`;VdKPVyl<*b?MyGD<(4F{N3h%`DPz_O3x2Q-ae=FQtFSxR&c{8IE+ zij24lJqlJaV7gXT7`OTDD$p^DHOpZkG_PRRC&F|G`FO2{$;(<*);nMo@+u{}K0(X| ziDdufnrl|IHxEyCp4<*(pv=$4O(h75>d170*Szwx6Pta%HpA&parA6MzTiHjJ*xfa z40q%>(LC$J6IK<~!ufBPH_o*?*KEq+P+QN8-N4J^M|ik>vkqtvi?4ynD%UYTiml6c z1TGV5elO{|*a1iagrBnyI_W@BhH;=*PJ#PReg?29I9c~Fd}ok?{%RMB6228^sbzOl zl;MmMY39ByD9K8*XF=>gS}5+EVs}w8I%tPwb8Pn>N?xcwqFO2~$mcB<_p2`p8Z$x9 zMzb2+2x;W|lR?F*i}ZjZ=2scQM$*Il`b^#$1K8ei+u_-vtg7e#7JOET|keNf!HDS)YeI2NpxqzFB_owxAg)Dl6=UfNdD@Hz;N zc1h^il9fOS@%cC_$k!Bc8*oR3>IUJ@C>;YYQU*6$x027Mf)yj%h>XiT8iq4kEZ)`X zU{0=xPC-+QMCZpTF82>|8N$r7&?(vOOoS>VxvnvY(@?P91Pcq&goNb{ke%E2(J*lE z9?xKPbH#7k7-z)V>ryt;@?fpnQmo1QiIrAokvj(u`)U-%1;m$P%HMefVYoe0CEM~1 z`Eow80n97Z{c@(zk5}KeL`F2CFV^3cJ3zPLC|sU7V7G)Ma!8fe<^wzVdKVn%ePPA#c}6$O+of z{kNHJ$@2)g#0%!<_>w2^Qv<>4WezQnIduV9Cn#xYbdHPrWox^`3$*!)|9wapBp$%Y zTG+12trRju+$r-@rF$u<940qA6QLNk*gVuh_O-gPIInbEJc zCr+{Ivqjw4JN=t9&0CM48>8)2%1OUGYFuae+7V>d9C@Qp`F!>~RR#&tY-O@t)W;+Y zJ!wJ4Km=0%lfq8E56*nhp2H<8Yx>Lh;UN-11I&oTV+68jk$d}988|+>BCZ~wFM`T| z;Y_2&e|H%~q&!hH8l)Qthd2U<92%c{#(7#}CVduwe-R+F>c6|-Oa;EiKjPv+teW)y z!T#IKQ)=yDvhBgF6S>at^TXCk1n8)UEQRw`Ppaq7or zi1h)ZZKhMRKZz*P2(;%pTV+xua#{Vg2Zg+R2F4qa_w4@- zcRzR71*pgHeo=hAlF=&Z)ZAPuTHqmBpazbY#3Mm2pA9xh$uqS_a`VTEDuyOz=tgRD zTR2%Tk^o<5iSc7subVoqds`w`%c=#$1-8-6w&gK`-O_;*Byz34g?n2hwWp792~V5y z4hKnicR^NL39oj)!gTRD&Uvw0itD_pgMpTdo(?T|K`rj$Yv>{pRFjvxUke;`k6r1a zhV8h2X8blWxYOM@|A8o!~pC( z01JtrB8b6YwidNTj5m;#8335EJMh%sDJWI=JJMK&necrvdqUz--_N-0fgbPc>x-eJ zgWh@;)!s|6D$NlDB%ea46`4JZi>;MWp^MThmqBjvV77CshiNRX&n9ZQ!Y$GlMvHz8 zEwEa1!&BHxVFx2bs9$WV9m|SZvNJsqjXNs)>;8o7Plc6FCO=u>Q7fM)f>t|{b2*Rm zU%F~ZRrAxQR#OV9iRUqptxp^Zmo)9a790|brYMYfZc%c9|3wz}mQ=bVJ^1__OD7pe z=5Kbcr%Q9`EAL^M%$wakM_q38W;?NSD)yR9Vol!o$J~3`^bpkH`|uh3TqkW?9fAqhldAAEK=vEJ;2lTH9=ecsX}Z8WZqrb?Jd~E zx`O5Yf(BE$m|Pa3(M{)aai#2Y#(aaw?Udz~tlFI|QRBAdk%MLXHO^sNfkv*8$dKYm z!y3IOPc)KVH^uNR^Zy81N|zF;dK_)#BXM1zo}-wmM@*!IbV{K5{#z)}p8vL3S`Wnz zU|5VlM>asn-FG9V;^6^BD(r>gz}M*%9^*0txo-t#xA1_CB)4nWt&{_A4A-0ubom#0 zy5qYiChWw!cP+bR0%Q6ibA!`%@{B_K*8GBt->cMQSp(;)U2}FTr)>%5yqZZhmYa>X zfk|-E!UM&3lF3i7F{CWm$)4}=_)09&I=hLN?gr$ZlcHAvRLu7Xugj&kX&x8%FVjVG zLOIB1jNA9sI%0PWwYFHwSRRC2*<#!PLC)^t7+&z{7KcnhBvKLR^=ZHb{9amP0mqzbo=XQqcxNffD z17~@4zq#QlNr;M67`@S93vDVpa(PJ7Zo4aHDkK_O|3bDF4Fj4_cV)9&a&NI9cU3ME@gA;M6N6zLlyO@S>_ux{~YIn9i zG^q{Vt)ml|z+<`;XiW1HPhXZCe_8D5f!qt1zN{%luZ-msi+x+OPweGXb{RXL%;7IY z&qje+G51eE*(s9ETKhe?RpuY5!I%x zxO>XcV%It&UMrTmu!2MfY?RW5wCYu62H&EWDz|v=MShxF*%VizXe?~sLqk_{gv|l=q%Qt(=i#$hy8-2HM*4PkeXY=48euo+n3>tiqOxe5=X?HZX(TJ z*XIvl2J5SobSjl7X<|!IF}YeTS)59IziYMmi#k0yzU#iX6wI-jES0Nvf~w|B1b4Ed z5rFH9{xC`cFsCD(G9P`8R{mSkIAl%|;CQJz5I+`7ddCI>*1$S7yjb?1`6hsO946_I zIqXeT^A%YkJX&ow%Ia*dhIz_iHQpepTVeEUn;Z1z#h`AvDLKi7R%G2TK(g<}T)V8Rc7Cb1 zCYaRENlHo=WcX7siYAXnBd(8pvdqQB@A0!Y-|eLiQ#-0?2ck{jFZGkD(9m$WIllc! zoztt#ez7xff`{-?;x zF(rtTDo!M?Xtb9>e+2*qGmt!)V2|y_XTmhBijO8E|IBXbE_#b+Hs`<%u12Fxd8dVFD;rEi%zos3p$c~!2$Is0FSlC5hC^-{QkN@~! zgArP6i&zz4<$0mOK1AsyvnKa20qLKe>f#J0eP&Ykw#>wIZ?mK@Eci?%Yjl%zyEf}b?c2m8s6r|hljUqji2&;xVL}iCE{o>olS66mhUn^(2pqfTDtz$J>i$E zy_!o|qYhzV408vU^8s#C5#2Fcpm!6zkX_&d0US&ePUU`S3+I2}sD)Zd*5^$lxY&Cx zpCwDz3lq=82CFvoV{Ib7YL-Pcn3(;Pg!NQ7S5@0|D% = { + red: { name: "red", light: "hsl(342.0779, 72%, 58%)", dark: "hsl(345.18988, 100%, 69%)" }, + orange: { name: "orange", light: "hsl(15.771428, 74%, 54%)", dark: "hsl(19.731544, 96%, 70%)" }, + yellow: { name: "yellow", light: "hsl(34.639175, 91%, 42%)", dark: "hsl(44.70588, 100%, 70%)" }, + green: { name: "green", light: "hsl(153.7815, 61%, 38%)", dark: "hsl(90, 59%, 66%)" }, + blue: { name: "blue", light: "hsl(192, 71%, 38%)", dark: "hsl(186.42857, 71%, 69%)" }, + purple: { name: "purple", light: "hsl(254.11766, 44%, 55%)", dark: "hsl(249.88234, 77%, 78%)" }, + teal: { name: "teal", light: "hsl(180, 65%, 35%)", dark: "hsl(183, 85%, 65%)" }, + pink: { name: "pink", light: "hsl(333.88236, 69%, 58%)", dark: "hsl(336.78634, 100%, 70%)" }, + cyan: { name: "cyan", light: "hsl(195, 71%, 35%)", dark: "hsl(193.41046, 72%, 70%)" }, + indigo: { name: "indigo", light: "hsl(232.05884, 61%, 42%)", dark: "hsl(234.61538, 73%, 71%)" }, + amber: { name: "amber", light: "hsl(38.57695, 94%, 44%)", dark: "hsl(40.35484, 100%, 72%)" }, + ochrea: { name: "ochrea", light: "hsl(45.75298, 95%, 45%)", dark: "hsl(46.09091, 100%, 78%)" }, + lime: { name: "lime", light: "hsl(86.25406, 60%, 42%)", dark: "hsl(86.84211, 93%, 71%)" }, + maroon: { name: "maroon", light: "hsl(321.81819, 26%, 41%)", dark: "hsl(322.10526, 66%, 66%)" }, + navy: { name: "navy", light: "hsl(216, 44%, 38%)", dark: "hsl(218, 69%, 69%)" }, + olive: { name: "olive", light: "hsl(81, 36%, 41%)", dark: "hsl(81, 64%, 65%)" }, + slate: { name: "slate", light: "hsl(212.53197, 15%, 44%)", dark: "hsl(212.90323, 61%, 72%)" }, +}; + +function parseHsl(value: string): [string, string, string] | undefined { + const match = /hsl\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*\)/.exec(value); + return match ? [match[1]!, match[2]!, match[3]!] : undefined; +} + +/** Builds the inline accent variables used by the theme, mirrors components/accent_color.html. */ +export function accentStyle(accent: string | [string, string?]): string { + const inputs = Array.isArray(accent) ? accent : [accent]; + const light = ACCENT_PRESETS[inputs[0] ?? ""]?.light ?? inputs[0]; + const dark = ACCENT_PRESETS[inputs[1] ?? inputs[0] ?? ""]?.dark ?? inputs[1] ?? ACCENT_PRESETS[inputs[0] ?? ""]?.dark ?? inputs[0]; + const lightHsl = parseHsl(light ?? ""); + const darkHsl = parseHsl(dark ?? ""); + if (!lightHsl || !darkHsl) throw new Error(`Unknown accent color: ${accent}`); + return `--accent-light-h:${lightHsl[0]};--accent-light-s:${lightHsl[1]};--accent-light-l:${lightHsl[2]};--accent-dark-h:${darkHsl[0]};--accent-dark-s:${darkHsl[1]};--accent-dark-l:${darkHsl[2]};`; +} + +const COMPONENTS = [ + "base", + "article", + "article-list", + "article-details", + "article-nav", + "paginator", + "alert", + "banner", + "icon", + "search-modal", + "tag-chips", + "audio", + "image", + "video", + "vimeo", + "youtube", + "mastodon", + "link", + "overflow-auto", + "crt", + "emoji", + "statement", + "comments", + "footnotes", + "skip-to-content", + "marquee", + "audio-button", + "deanchor", + "fuse", + "search-fuse", +] as const; + +function componentTemplates(): [string, string][] { + return COMPONENTS.map((name) => [`amezing-${name}`, fileURLToPath(new URL(`./components/${name}.spug`, import.meta.url))]); +} + +interface TemplateViews { + component(name: string, component: unknown): unknown; + engine?: { renderComponent(path: string, props: Record, context: { locals: Record; params: Record }): Promise }; +} + +function registerAll(views: TemplateViews, accent: string | undefined): void { + for (const [name, template] of componentTemplates()) { + views.component(name, { + async render(props: Record, context: { locals: Record; params: Record }) { + return views.engine!.renderComponent(template, { accentStyle: accent, ...props }, context); + }, + }); + } +} + +/** Registers the Ametrine components on the views registry. */ +export function registerAmezing(views: Views, options: { accentColor?: string | [string, string?] } = {}): void { + registerAll(views as unknown as Parameters[0], options.accentColor ? accentStyle(options.accentColor) : undefined); +} + +/** Compiled theme stylesheet (Citrine base + Ametrine layout). */ +export async function amezingStyles(): Promise { + const [theme, vars] = await Promise.all([ + readFile(new URL("./styles/ametrine.css", import.meta.url), "utf8"), + readFile(new URL("./styles/vars.css", import.meta.url), "utf8"), + ]); + return vars + "\n" + theme; +} + +export const amezingScripts: Record = { + "closable.js": "scripts/closable.js", + "paginator.js": "scripts/paginator.js", + "deanchor.js": "scripts/deanchor.js", + "marquee.js": "scripts/marquee.js", + "audio-button.js": "scripts/audio-button.js", + "fuse.js": "scripts/fuse.js", + "search-fuse.js": "scripts/search-fuse.js", +}; + +export const amezingFonts: Record = { + "/fonts/commissioner.woff2": "fonts/commissioner.woff2", + "/fonts/commit-mono.woff2": "fonts/commit-mono.woff2", +}; + +export function amezingMount(options: { accentColor?: string | [string, string?] } = {}): Mounts { + const accent = options.accentColor ? accentStyle(options.accentColor) : undefined; + return { + views: (views) => registerAll(views as unknown as Parameters[0], accent), + routes: (route) => { + for (const [routePath, file] of Object.entries(amezingScripts)) { + route("GET", `/_muddle/amezing/${routePath}`, async () => + new Response(await readFile(new URL(`./${file}`, import.meta.url), "utf8"), { + headers: { "content-type": "text/javascript; charset=utf-8" }, + })); + } + for (const [routePath, file] of Object.entries(amezingFonts)) { + route("GET", routePath, async () => + new Response(await readFile(new URL(`./${file}`, import.meta.url)), { + headers: { "content-type": "font/woff2" }, + })); + } + }, + styles: { "/_muddle/amezing.css": () => amezingStyles() }, + }; +} + +/** Convenience integration. */ +export function amezingIntegration(options: { accentColor?: string | [string, string?] } = {}): Integration { + return { name: "amezing", mounts: [amezingMount(options)] }; +} diff --git a/modules/amezing/package.json b/modules/amezing/package.json new file mode 100644 index 0000000..f153655 --- /dev/null +++ b/modules/amezing/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/amezing", + "version": "0.1.0", + "type": "module", + "exports": { ".": "./index.ts", "./styles": "./styles/ametrine.css" }, + "peerDependencies": { "muddle": "*" } +} diff --git a/modules/amezing/sass/amezing.scss b/modules/amezing/sass/amezing.scss new file mode 100644 index 0000000..224f2d1 --- /dev/null +++ b/modules/amezing/sass/amezing.scss @@ -0,0 +1,7 @@ +/* +* Ametrine port for muddle. Combines the Citrine base with the Ametrine layout. +*/ + +@use "fonts"; +@use "citrine/style"; +@use "style" as ametrine; diff --git a/modules/amezing/sass/citrine/abstracts/_functions.scss b/modules/amezing/sass/citrine/abstracts/_functions.scss new file mode 100644 index 0000000..dc7e8f9 --- /dev/null +++ b/modules/amezing/sass/citrine/abstracts/_functions.scss @@ -0,0 +1,5 @@ +// Converts px values to rem +// rpx(1) --> 0.0625rem +@function rpx($px-value) { + @return ($px-value / 16) + rem; +} diff --git a/modules/amezing/sass/citrine/abstracts/_mixins.scss b/modules/amezing/sass/citrine/abstracts/_mixins.scss new file mode 100644 index 0000000..6a0739b --- /dev/null +++ b/modules/amezing/sass/citrine/abstracts/_mixins.scss @@ -0,0 +1,82 @@ +@mixin icon($mode: normal) { + @if $mode == normal { + font-style: normal; + font-variant: normal; + font-weight: normal; + line-height: 1; + user-select: none; + text-transform: none; + -webkit-user-select: none; + display: inline-block; + box-sizing: content-box; + } @else if $mode == pseudo { + -webkit-mask-image: var(--icon); + -webkit-mask-size: cover; + display: block; + mask-image: var(--icon); + mask-size: cover; + background-color: currentColor; + width: 1em; + height: 1em; + content: ""; + } +} + +@mixin gradient-fill($mode: normal) { + @if $mode == normal { + box-shadow: var(--edge-highlight), var(--shadow-glow); + background-image: linear-gradient( + to bottom right, + var(--accent-color), + var(--accent-highlight) + ); + color: var(--contrast-color); + text-shadow: var(--text-shadow-glow); + } @else if $mode == hover { + box-shadow: var(--edge-highlight); + background-color: var(--accent-color); + color: var(--contrast-color); + } +} + +@mixin card { + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; +} + +@mixin chip { + transition: + all var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding-inline: 0.375rem; + text-decoration: none; +} + +@mixin easing-mask($direction: to bottom) { + --mask-image: linear-gradient( + to #{$direction}, + hsl(0, 0%, 0%) 0%, + hsla(0, 0%, 0%, 0.987) 8.1%, + hsla(0, 0%, 0%, 0.951) 15.5%, + hsla(0, 0%, 0%, 0.896) 22.5%, + hsla(0, 0%, 0%, 0.825) 29%, + hsla(0, 0%, 0%, 0.741) 35.3%, + hsla(0, 0%, 0%, 0.648) 41.2%, + hsla(0, 0%, 0%, 0.55) 47.1%, + hsla(0, 0%, 0%, 0.45) 52.9%, + hsla(0, 0%, 0%, 0.352) 58.8%, + hsla(0, 0%, 0%, 0.259) 64.7%, + hsla(0, 0%, 0%, 0.175) 71%, + hsla(0, 0%, 0%, 0.104) 77.5%, + hsla(0, 0%, 0%, 0.049) 84.5%, + hsla(0, 0%, 0%, 0.013) 91.9%, + hsla(0, 0%, 0%, 0) 100% + ); + mask-image: var(--mask-image); + -webkit-mask-image: var(--mask-image); +} diff --git a/modules/amezing/sass/citrine/abstracts/_variables.scss b/modules/amezing/sass/citrine/abstracts/_variables.scss new file mode 100644 index 0000000..0a7019d --- /dev/null +++ b/modules/amezing/sass/citrine/abstracts/_variables.scss @@ -0,0 +1,570 @@ +@use "functions" as *; +@use "mixins" as *; + +@mixin variables($modes: "light") { + @each $mode in $modes { + @if $mode == "light" { + // COLORS + --bg-overlay: linear-gradient( + rgba(255, 255, 255, var(--dim-opacity)), + rgba(255, 255, 255, var(--dim-opacity)) + ); + --fg-color: #29242a; + --fg-contrast: #fcfcfa; + --red-fg: hsl(342.0779, 72%, 58%); + --red-h: 342.0779; + --red-s: 72%; + --red-l: 58%; + --orange-fg: hsl(15.771428, 74%, 54%); + --orange-h: 15.771428; + --orange-s: 74%; + --orange-l: 54%; + --yellow-fg: hsl(34.639175, 91%, 42%); + --yellow-h: 34.639175; + --yellow-s: 91%; + --yellow-l: 42%; + --green-fg: hsl(153.7815, 61%, 38%); + --green-h: 153.7815; + --green-s: 61%; + --green-l: 38%; + --blue-fg: hsl(192, 71%, 38%); + --blue-h: 192; + --blue-s: 71%; + --blue-l: 38%; + --purple-fg: hsl(254.11766, 44%, 55%); + --purple-h: 254.11766; + --purple-s: 44%; + --purple-l: 55%; + --red-bg-1: hsla( + var(--red-h), + var(--red-s), + var(--red-l), + var(--color-opacity-1) + ); + --red-bg-2: hsla( + var(--red-h), + var(--red-s), + var(--red-l), + var(--color-opacity-2) + ); + --orange-bg-1: hsla( + var(--orange-h), + var(--orange-s), + var(--orange-l), + var(--color-opacity-1) + ); + --orange-bg-2: hsla( + var(--orange-h), + var(--orange-s), + var(--orange-l), + var(--color-opacity-2) + ); + --yellow-bg-1: hsla( + var(--yellow-h), + var(--yellow-s), + var(--yellow-l), + var(--color-opacity-1) + ); + --yellow-bg-2: hsla( + var(--yellow-h), + var(--yellow-s), + var(--yellow-l), + var(--color-opacity-2) + ); + --green-bg-1: hsla( + var(--green-h), + var(--green-s), + var(--green-l), + var(--color-opacity-1) + ); + --green-bg-2: hsla( + var(--green-h), + var(--green-s), + var(--green-l), + var(--color-opacity-2) + ); + --blue-bg-1: hsla( + var(--blue-h), + var(--blue-s), + var(--blue-l), + var(--color-opacity-1) + ); + --blue-bg-2: hsla( + var(--blue-h), + var(--blue-s), + var(--blue-l), + var(--color-opacity-2) + ); + --purple-bg-1: hsla( + var(--purple-h), + var(--purple-s), + var(--purple-l), + var(--color-opacity-1) + ); + --purple-bg-2: hsla( + var(--purple-h), + var(--purple-s), + var(--purple-l), + var(--color-opacity-2) + ); + --red-fg-muted-1: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) - var(--red-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --red-fg-muted-2: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) - var(--red-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --orange-fg-muted-1: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) - var(--orange-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --orange-fg-muted-2: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) - var(--orange-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --yellow-fg-muted-1: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) - var(--yellow-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --yellow-fg-muted-2: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) - var(--yellow-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --green-fg-muted-1: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) - var(--green-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --green-fg-muted-2: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) - var(--green-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --blue-fg-muted-1: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) - var(--blue-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --blue-fg-muted-2: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) - var(--blue-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --purple-fg-muted-1: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) - var(--purple-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --purple-fg-muted-2: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) - var(--purple-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --shade-color: rgba(0, 0, 0, 0.07); + + // COLORS (NEUTRAL) + --bg-muted-neutral-1: rgba(0, 0, 0, var(--color-opacity-1)); + --bg-muted-neutral-2: rgba(0, 0, 0, var(--color-opacity-2)); + --fg-muted-neutral-1: rgba(0, 0, 0, var(--fg-muted-1-opacity)); + --fg-muted-neutral-2: rgba(0, 0, 0, var(--fg-muted-2-opacity)); + + // OPACITY + --scanlines-opacity: 0.4; + --fg-muted-1-opacity: 0.5; + --fg-muted-2-opacity: 0.6; + + // SHADOWS + --edge-highlight: inset + 0 + #{rpx(-1)} + 0 + rgba(0, 0, 0, var(--color-opacity-1)); + } @else if $mode == "dark" { + // COLORS + --bg-overlay: linear-gradient( + rgba(0, 0, 0, var(--dim-opacity)), + rgba(0, 0, 0, var(--dim-opacity)) + ); + --fg-color: #fcfcfa; + --fg-contrast: #29242a; + --red-fg: hsl(345.18988, 100%, 69%); + --red-h: 345.18988; + --red-s: 100%; + --red-l: 69%; + --orange-fg: hsl(19.731544, 96%, 70%); + --orange-h: 19.731544; + --orange-s: 96%; + --orange-l: 70%; + --yellow-fg: hsl(44.70588, 100%, 70%); + --yellow-h: 44.70588; + --yellow-s: 100%; + --yellow-l: 70%; + --green-fg: hsl(90, 59%, 66%); + --green-h: 90; + --green-s: 59%; + --green-l: 66%; + --blue-fg: hsl(186.42857, 71%, 69%); + --blue-h: 186.42857; + --blue-s: 71%; + --blue-l: 69%; + --purple-fg: hsl(249.88234, 77%, 78%); + --purple-h: 249.88234; + --purple-s: 77%; + --purple-l: 78%; + + --red-fg-muted-1: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) + (100% - var(--red-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --red-fg-muted-2: hsla( + var(--red-h), + var(--red-s), + calc(var(--red-l) + (100% - var(--red-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --orange-fg-muted-1: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --orange-fg-muted-2: hsla( + var(--orange-h), + var(--orange-s), + calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --yellow-fg-muted-1: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --yellow-fg-muted-2: hsla( + var(--yellow-h), + var(--yellow-s), + calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --green-fg-muted-1: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) + (100% - var(--green-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --green-fg-muted-2: hsla( + var(--green-h), + var(--green-s), + calc(var(--green-l) + (100% - var(--green-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --blue-fg-muted-1: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --blue-fg-muted-2: hsla( + var(--blue-h), + var(--blue-s), + calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --purple-fg-muted-1: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --purple-fg-muted-2: hsla( + var(--purple-h), + var(--purple-s), + calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --shade-color: rgba(0, 0, 0, 0.25); + + // COLORS (NEUTRAL) + --bg-muted-neutral-1: rgba(255, 255, 255, var(--color-opacity-1)); + --bg-muted-neutral-2: rgba(255, 255, 255, var(--color-opacity-2)); + --fg-muted-neutral-1: rgba(255, 255, 255, var(--fg-muted-1-opacity)); + --fg-muted-neutral-2: rgba(255, 255, 255, var(--fg-muted-2-opacity)); + + // OPACITY + --scanlines-opacity: 1; + --fg-muted-1-opacity: 0.6; + --fg-muted-2-opacity: 0.7; + + // SHADOWS + --edge-highlight: inset + 0 + #{rpx(1)} + 0 + rgba(255, 255, 255, var(--color-opacity-1)); + } @else if $mode == "common" { + // WIDTHS + --content-width: #{rpx(720)}; + --sidebar-width: clamp( + calc(var(--content-width) / 3), + 20vw, + calc(var(--content-width) / 2) + ); + + // CORNERS + --rounded-corner: 0.75rem; + --rounded-corner-small: 0.5rem; + + // FILTERS + --blur: blur(0.75rem) saturate(180%); + --printed: contrast(2) brightness(0.75) grayscale(1); + --drop-shadow: drop-shadow(0 0.08rem 0.1rem rgba(0, 0, 0, 0.35)) + drop-shadow(0 0.4rem 0.7rem rgba(0, 0, 0, 0.18)); + + // FONTS + --font-system-ui: system-ui, sans-serif; + --font-ui-monospace: ui-monospace, monospace; + --font-emoji: + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", + "Noto Color Emoji"; + + // FONT SIZES + --font-size-xx-small: 0.625rem; + --font-size-x-small: 0.75rem; + --font-size-small: 0.875rem; + --font-size-medium: 1rem; + --font-size-large: 1.25rem; + --font-size-x-large: 1.5rem; + --font-size-xx-large: 2rem; + --font-size-xxx-large: 3rem; + + // FONT WEIGHTS + --font-weight-thin: 100; + --font-weight-extra-light: 200; + --font-weight-light: 300; + --font-weight-normal: 400; + --font-weight-medium: 500; + --font-weight-semi-bold: 600; + --font-weight-bold: 700; + --font-weight-extra-bold: 800; + --font-weight-black: 900; + + // OPACITY + --color-opacity-1: 0.1; + --color-opacity-2: 0.2; + --dim-opacity: 0.8; + --disabled-opacity: 0.4; + + // SHADOWS + --shadow: + 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03), + 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07), + 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03); + --shadow-inset: + inset 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.03), + inset 0 #{rpx(1)} #{rpx(3)} #{rpx(1)} rgba(0, 0, 0, 0.07), + inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.03); + --shadow-raised: + 0 0 0 #{rpx(1)} rgba(0, 0, 0, 0.06), + 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} rgba(0, 0, 0, 0.14), + 0 #{rpx(4)} #{rpx(12)} #{rpx(4)} rgba(0, 0, 0, 0.06); + --shadow-glass: 0 #{rpx(6)} #{rpx(12)} #{rpx(-6)} rgba(0, 0, 0, 0.1); + + // STATES + --hover: scale(1.1); + --active: scale(0.9); + + // TRANSITIONS + --transition: 0.2s; + --transition-longer: 0.4s; + --transition-long: 0.8s; + --transition-bezier: 0.35s cubic-bezier(0.17, 0.89, 0.32, 1.28); + --transition-bezier-longer: 0.525s cubic-bezier(0.17, 0.89, 0.32, 1.28); + + // ICONS + --icon-arrow-square-out: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M228%2C104a12%2C12%2C0%2C0%2C1-24%2C0V69l-59.51%2C59.51a12%2C12%2C0%2C0%2C1-17-17L187%2C52H152a12%2C12%2C0%2C0%2C1%2C0-24h64a12%2C12%2C0%2C0%2C1%2C12%2C12Zm-44%2C24a12%2C12%2C0%2C0%2C0-12%2C12v64H52V84h64a12%2C12%2C0%2C0%2C0%2C0-24H48A20%2C20%2C0%2C0%2C0%2C28%2C80V208a20%2C20%2C0%2C0%2C0%2C20%2C20H176a20%2C20%2C0%2C0%2C0%2C20-20V140A12%2C12%2C0%2C0%2C0%2C184%2C128Z%22/%3E%3C/svg%3E); + --icon-caret-down: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M216.49%2C104.49l-80%2C80a12%2C12%2C0%2C0%2C1-17%2C0l-80-80a12%2C12%2C0%2C0%2C1%2C17-17L128%2C159l71.51-71.52a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E); + --icon-check: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M232.49%2C80.49l-128%2C128a12%2C12%2C0%2C0%2C1-17%2C0l-56-56a12%2C12%2C0%2C1%2C1%2C17-17L96%2C183%2C215.51%2C63.51a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E); + } + } +} + +@mixin variables-accent($modes: "light") { + @each $mode in $modes { + @if $mode == "light" { + --accent-color: hsl( + var(--accent-light-h), + var(--accent-light-s), + var(--accent-light-l) + ); + --accent-h: var(--accent-light-h); + --accent-s: var(--accent-light-s); + --accent-l: var(--accent-light-l); + + --bg-color: hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.6), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) + ); + --fg-muted-1: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) - var(--accent-l) * 0.85), + var(--fg-muted-1-opacity) + ); + --fg-muted-2: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) - var(--accent-l) * 0.85), + var(--fg-muted-2-opacity) + ); + --glass-bg-1: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.7), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), + var(--dim-opacity) + ); + --glass-bg-2: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.6), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), + var(--dim-opacity) + ); + --crt-bg: radial-gradient( + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) + ), + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) + ) + 80%, + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) + ) + ); + } @else if $mode == "dark" { + --accent-color: hsl( + var(--accent-dark-h), + var(--accent-dark-s), + var(--accent-dark-l) + ); + --accent-h: var(--accent-dark-h); + --accent-s: var(--accent-dark-s); + --accent-l: var(--accent-dark-l); + + --bg-color: hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.7), + calc(var(--accent-l) - var(--accent-l) * 0.9) + ); + --fg-muted-1: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), + var(--fg-muted-1-opacity) + ); + --fg-muted-2: hsla( + var(--accent-h), + var(--accent-s), + calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), + var(--fg-muted-2-opacity) + ); + --glass-bg-1: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.6), + calc(var(--accent-l) - var(--accent-l) * 0.8), + var(--dim-opacity) + ); + --glass-bg-2: hsla( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.7), + var(--dim-opacity) + ); + --crt-bg: radial-gradient( + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.7) + ), + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.9) + ) + 80%, + hsl( + var(--accent-h), + calc(var(--accent-s) - var(--accent-s) * 0.5), + calc(var(--accent-l) - var(--accent-l) * 0.95) + ) + ); + } @else if $mode == "common" { + // COLORS + --accent-highlight: hsla( + var(--accent-h), + var(--accent-s), + var(--accent-l), + var(--disabled-opacity) + ); + + // TODO: put this all in sub-mixins + --bg-muted-1: hsla( + var(--accent-h), + var(--accent-s), + var(--accent-l), + var(--color-opacity-1) + ); + + --bg-muted-2: hsla( + var(--accent-h), + var(--accent-s), + var(--accent-l), + var(--color-opacity-2) + ); + + // CONTRAST COLOR + --l: clamp(0, (l / 0.623 - 1) * -infinity, 1); + --a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l)))); + --contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a)); + + // SHADOWS + --shadow-glow: + 0 0 0 #{rpx(1)} var(--bg-muted-2), + 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1), + 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2); + --shadow-glow-inset: + inset 0 0 0 #{rpx(1)} var(--bg-muted-2), + inset 0 #{rpx(2)} #{rpx(6)} #{rpx(2)} var(--bg-muted-1), + inset 0 #{rpx(4)} #{rpx(24)} #{rpx(4)} var(--bg-muted-2); + --text-shadow-glow: + var(--bg-muted-2) 0 0 #{rpx(4)}, var(--accent-color) 0 0 #{rpx(12)}; + } + } +} diff --git a/modules/amezing/sass/citrine/base/_animations.scss b/modules/amezing/sass/citrine/base/_animations.scss new file mode 100644 index 0000000..1c61ed2 --- /dev/null +++ b/modules/amezing/sass/citrine/base/_animations.scss @@ -0,0 +1,83 @@ +@keyframes slide-in-up { + from { + transform: translateY(-1rem); + opacity: 0; + } +} + +@keyframes slide-in-left { + from { + transform: translateX(1rem); + opacity: 0; + } +} + +@keyframes slide-in-right { + from { + transform: translateX(-1rem); + opacity: 0; + } +} + +@keyframes slide-in-up-longer { + from { + transform: translateY(-2rem); + opacity: 0; + } +} + +@keyframes slide-in-down-longer { + from { + transform: translateY(2rem); + opacity: 0; + } +} + +@keyframes slide-in-left-longer { + from { + transform: translateX(-2rem); + opacity: 0; + } +} + +@keyframes slide-in-right-longer { + from { + transform: translateX(2rem); + opacity: 0; + } +} + +@keyframes slide-out-down-longer { + to { + transform: translateY(-2rem); + opacity: 0; + } +} + +@keyframes expand-up-left { + from { + transform: scale(0.5) translate(-1rem, -1rem); + opacity: 0; + } +} + +@keyframes expand-up-right { + from { + transform: scale(0.5) translate(1rem, -1rem); + opacity: 0; + } +} + +@keyframes expand-down-left { + from { + transform: scale(0.5) translate(-1rem, 1rem); + opacity: 0; + } +} + +@keyframes expand-down-right { + from { + transform: scale(0.5) translate(1rem, 1rem); + opacity: 0; + } +} diff --git a/modules/amezing/sass/citrine/base/_base.scss b/modules/amezing/sass/citrine/base/_base.scss new file mode 100644 index 0000000..01ea5eb --- /dev/null +++ b/modules/amezing/sass/citrine/base/_base.scss @@ -0,0 +1,59 @@ +@use "../abstracts/functions" as *; + +// Make focused anchor not get covered by header +:target:not(#main-content) { + scroll-margin-block-start: 6.5rem; +} + +:focus-visible, +:target:not(#main-content) { + animation: focus-in var(--transition); + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.125rem; + + @keyframes focus-in { + from { + outline: 0.5rem solid transparent; + outline-offset: 0.25rem; + } + } +} + +// Style text selection to use accent color +::selection { + background-color: var(--accent-color); + color: var(--contrast-color); +} + +body { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background-color: var(--bg-color); + color: var(--fg-color); + font-family: var(--font-sans-serif), var(--font-system-ui), var(--font-emoji); + overflow-wrap: break-word; + + @supports (min-height: 100dvh) { + min-height: 100dvh; + } +} + +@media (prefers-reduced-motion) { + *, + *::before, + *::after { + animation-duration: 0s !important; + transition-duration: 0s !important; + } +} + +// Disable blur on Firefox Mobile +@-moz-document url-prefix() { + @media only screen and (max-width: 960px) { + .firefox-unblur, + .firefox-unblur::before, + .firefox-unblur::after { + backdrop-filter: none !important; + } + } +} diff --git a/modules/amezing/sass/citrine/base/_layout.scss b/modules/amezing/sass/citrine/base/_layout.scss new file mode 100644 index 0000000..ffbe1e5 --- /dev/null +++ b/modules/amezing/sass/citrine/base/_layout.scss @@ -0,0 +1,36 @@ +@use "../abstracts/functions" as *; + +body > main, +body > nav, +body > footer { + --padding: 1rem; + margin-inline: auto; + padding-inline: 1rem; + max-width: calc(var(--content-width) + var(--padding) * 2); +} + +body > footer { + margin-block-start: 4rem; +} + +body > nav { + margin-block-end: 4rem; + border-block-end: rpx(1) solid var(--bg-muted-2); + + ul { + display: flex; + gap: 0.25rem; + margin-block: 0.625rem; + padding-inline-start: 0; + list-style: none; + + @media only screen and (max-width: 480px) { + flex-direction: column; + } + } + + li { + margin-block: 0; + padding-inline-start: 0; + } +} diff --git a/modules/amezing/sass/citrine/base/_reset.scss b/modules/amezing/sass/citrine/base/_reset.scss new file mode 100644 index 0000000..2365866 --- /dev/null +++ b/modules/amezing/sass/citrine/base/_reset.scss @@ -0,0 +1,135 @@ +// Based on css-init, with a few important adjustments +// https://github.com/sir-sharkey/css-init + +*, +:after, +:before { + vertical-align: baseline; + box-sizing: border-box; + margin: 0; + border: none; + background: none; + padding: 0; + color: inherit; + font: inherit; + -webkit-tap-highlight-color: transparent; +} + +button, +select { + appearance: none; +} + +body { + -webkit-text-size-adjust: none; + text-size-adjust: none; + width: 100%; + min-height: 100vh; + line-height: 1.5; +} + +a, +button, +summary, +input[type="button"], +input[type="checkbox"], +input[type="color"], +input[type="file"], +input[type="image"], +input[type="radio"], +input[type="range"], +input[type="reset"], +input[type="submit"], +label, +select { + cursor: pointer; +} + +article, +aside, +audio, +canvas, +details, +figcaption, +figure, +footer, +header, +main, +nav, +rt, +ruby, +section, +summary { + display: block; +} + +bdi, +mark, +meter, +output, +progress, +time { + display: inline-block; +} + +[hidden], +datalist, +dialog:not([open]), +rp { + display: none !important; +} + +embed, +iframe, +img, +object, +picture, +svg, +video { + display: block; + max-width: 100%; + height: auto; +} + +blockquote, +q { + quotes: none; +} + +blockquote:after, +blockquote:before, +q:after, +q:before { + content: none; +} + +strong { + font-weight: 700; +} + +em { + font-style: italic; +} + +code, +kbd, +pre, +samp { + font-family: monospace; +} + +sub { + vertical-align: sub; +} + +sup { + vertical-align: super; +} + +small { + font-size: small; +} + +table { + border-collapse: collapse; +} diff --git a/modules/amezing/sass/citrine/base/_root.scss b/modules/amezing/sass/citrine/base/_root.scss new file mode 100644 index 0000000..e245c1d --- /dev/null +++ b/modules/amezing/sass/citrine/base/_root.scss @@ -0,0 +1,20 @@ +@use "../abstracts/variables" as *; + +:root { + @include variables(("light", "common")); + @include variables-accent(("light", "common")); + scrollbar-color: var(--accent-color) transparent; + accent-color: var(--accent-color); + + @media (prefers-reduced-motion: no-preference) { + scroll-behavior: smooth; + } + + @media (prefers-color-scheme: dark) { + & { + @include variables("dark"); + @include variables-accent(("dark")); + color-scheme: dark; + } + } +} diff --git a/modules/amezing/sass/citrine/base/_typography.scss b/modules/amezing/sass/citrine/base/_typography.scss new file mode 100644 index 0000000..0415adb --- /dev/null +++ b/modules/amezing/sass/citrine/base/_typography.scss @@ -0,0 +1,400 @@ +@use "../abstracts/functions" as *; +@use "../abstracts/mixins" as *; + +p, +ul, +ol, +dl, +blockquote, +pre { + margin-block: 1rem; +} + +ul ul, +ul ol, +ol ul, +ol ol { + margin-block: 0; +} + +p { + text-wrap: pretty; + overflow-wrap: break-word; +} + +:disabled, +[aria-disabled="true"] { + opacity: var(--disabled-opacity); + cursor: not-allowed; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + transition-duration: var(--transition); + transition-property: text-shadow, font-weight; + margin-block: 2rem 1rem; + color: var(--accent-color); + font-weight: var(--font-weight-bold); + line-height: 1; + font-variation-settings: "FLAR" 100; + letter-spacing: -0.05em; + text-decoration: underline; + text-decoration-color: var(--bg-muted-1); + text-decoration-thickness: 0.125em; + text-wrap: balance; + overflow-wrap: break-word; + + &:target { + animation: none !important; + outline: none !important; + font-weight: var(--font-weight-light); + text-shadow: var(--text-shadow-glow); + } +} + +h1 { + font-size: var(--font-size-xxx-large); +} + +h2 { + font-size: var(--font-size-xx-large); +} + +h3 { + font-size: var(--font-size-x-large); +} + +h4 { + font-size: var(--font-size-large); +} + +h5 { + font-size: var(--font-size-medium); +} + +h6 { + font-size: var(--font-size-small); +} + +@media only screen and (max-width: 480px) { + h1 { + font-size: var(--font-size-xx-large); + } + + h2 { + font-size: var(--font-size-x-large); + } + + h3 { + font-size: var(--font-size-large); + } + + h4 { + font-size: var(--font-size-medium); + } + + h5 { + font-size: var(--font-size-small); + } + + h6 { + font-size: var(--font-size-x-small); + } +} + +small { + color: var(--fg-muted-2); + font-size: var(--font-size-small); +} + +abbr[title] { + cursor: help; + text-decoration: underline; + text-decoration-style: dotted; + text-decoration-thickness: rpx(1); +} + +figcaption { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + text-align: center; +} + +blockquote { + margin: 1rem 0; + border-inline-start: 0.25rem solid var(--accent-color); + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; + padding-inline-start: 0.75rem; + color: var(--fg-muted-2); +} + +pre, +code, +kbd, +samp { + font-family: var(--font-monospace), var(--font-ui-monospace); +} + +pre { + margin: 1rem 0; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.75rem 1rem; + overflow-x: auto; + scrollbar-width: thin; + line-height: 1.25; +} + +code:not(pre code), +mark, +samp, +q { + @include chip; +} + +code:not(pre code) { + background-color: var(--red-bg-1); + padding-block: rpx(3); + color: var(--red-fg); + font-size: var(--font-size-small); +} + +mark { + color: var(--accent-color); +} + +del { + color: var(--red-fg); + text-decoration: line-through; + text-decoration-thickness: rpx(1); +} + +ins { + color: var(--green-fg); + text-decoration: underline; + text-decoration-thickness: rpx(1); +} + +samp { + color: var(--fg-muted-2); + font-size: var(--font-size-small); +} + +q { + color: var(--fg-muted-2); + font-style: italic; +} + +u { + text-decoration: underline; + text-decoration-style: wavy; + text-decoration-color: var(--red-fg); + text-decoration-thickness: rpx(1); +} + +progress { + appearance: none; + box-shadow: var(--edge-highlight); + border: none; + border-radius: 9999px; + background-color: var(--bg-muted-1); + padding: 0.25rem; + width: 100%; + height: 1rem; + color: var(--accent-color); + + &:indeterminate { + background-color: var(--bg-muted-1); + } + + &:indeterminate::-moz-progress-bar { + background-color: transparent; + } + + &::-webkit-progress-bar { + background-color: transparent; + } + + &::-moz-progress-bar { + border-radius: 9999px; + background-color: var(--accent-color); + } + + &::-webkit-progress-value { + border-radius: 9999px; + background-color: var(--accent-color); + } +} + +kbd { + display: inline-block; + transition-duration: var(--transition); + transition-property: transform, box-shadow, background-color; + cursor: pointer; + box-shadow: + var(--edge-highlight), + inset 0 -0.125rem 0 var(--bg-muted-2); + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding: 0 0.375rem; + font-size: var(--font-size-small); + + // Small nice thingy, keys can be pressed! + &:active { + transform: translateY(0.125rem); + box-shadow: inset 0 rpx(1) 0 var(--bg-muted-2); + background-color: var(--bg-muted-2); + } +} + +a { + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + text-decoration-color: var(--accent-highlight); + text-decoration-thickness: rpx(1); + + &:hover { + text-decoration-style: wavy; + } +} + +hr { + margin: 2rem auto; + border: 0.125rem solid var(--bg-muted-2); + border-radius: 9999px; +} + +dt { + font-weight: var(--font-weight-semi-bold); +} + +dd { + margin-inline-start: 1.5rem; + margin-block-end: 1rem; +} + +aside { + float: inline-end; + margin-inline-start: 1rem; + margin-block-end: 1rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; + width: calc(var(--content-width) / 3); + + @media only screen and (max-width: 720px) { + float: none; + margin-inline-start: 0; + width: 100%; + } + + :first-child { + margin-block-start: 0; + } + + :last-child { + margin-block-end: 0; + } +} + +details { + display: flow-root; + transition: padding-block-end var(--transition); + margin-block-end: 1rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.75rem 1rem; + + &::details-content { + display: block; + opacity: 0; + transition-behavior: allow-discrete; + transition-duration: var(--transition); + transition-property: opacity, block-size, content-visibility; + block-size: 0; + overflow: hidden; + } + + &[open] { + padding-block-end: 1rem; + + &::details-content { + opacity: 1; + block-size: auto; + block-size: calc-size(auto, size); + } + + summary { + color: var(--accent-color); + + &::before { + transform: scaleY(-1); + } + + @supports not (block-size: calc-size(auto, size)) { + & ~ * { + animation: slide-in-up var(--transition-bezier); + } + } + } + } + + summary { + transition: var(--transition); + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + list-style: none; + + &::-webkit-details-marker { + display: none; + } + + &::before { + @include icon(pseudo); + --icon: var(--icon-caret-down); + display: inline-block; + vertical-align: rpx(-2); + transition: + all var(--transition), + transform var(--transition-bezier); + margin-inline-end: 0.25rem; + } + } + + & > :last-child, + & > :last-child img { + margin-block-end: 0; + } +} + +figure { + margin-inline: 0; +} + +ol, +ul { + padding-inline-start: 1.5rem; +} + +li { + margin-block: 0.25rem; + padding-inline-start: 0.25rem; + + &::marker { + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + } +} + +b, +strong { + font-weight: var(--font-weight-semi-bold); +} diff --git a/modules/amezing/sass/citrine/components/_buttons.scss b/modules/amezing/sass/citrine/components/_buttons.scss new file mode 100644 index 0000000..9667b7e --- /dev/null +++ b/modules/amezing/sass/citrine/components/_buttons.scss @@ -0,0 +1,100 @@ +@use "../abstracts/mixins" as *; + +.buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + gap: 0.25rem; + margin-block-start: 2rem; + + &.centered { + justify-content: center; + } + + &.start { + justify-content: flex-start; + } + + &.end { + justify-content: flex-end; + } + + &.big { + gap: 0.5rem; + } +} + +.buttons a, +a.button, +button { + display: inline-block; + transition: + background-color var(--transition), + color var(--transition), + box-shadow var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.5rem 1rem; + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-small); + line-height: 1.5rem; + text-decoration: none; + + &:not(:disabled, [aria-disabled="true"]):hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-2); + } + + &:not(:disabled, [aria-disabled="true"]):active { + transform: var(--active); + } + + &.suggested { + @include gradient-fill; + + &:not(:disabled, [aria-disabled="true"]):hover { + @include gradient-fill(hover); + } + } + + &.subtle { + box-shadow: none; + background-color: transparent; + + &:not(:disabled, [aria-disabled="true"]):hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + } + } + + &[aria-busy="true"] { + animation: progress-shimmer var(--transition-long) ease-in-out alternate + infinite; + background-image: linear-gradient( + to right, + transparent 50%, + var(--bg-muted-1) 75%, + transparent 100% + ); + background-size: 200%; + + @keyframes progress-shimmer { + to { + background-position-x: -200%; + } + } + } +} + +.buttons.big a, +.buttons.big button, +a.button.big, +button.big { + border-radius: 9999px; + padding: 0.625rem 1.25rem; +} diff --git a/modules/amezing/sass/citrine/components/_card.scss b/modules/amezing/sass/citrine/components/_card.scss new file mode 100644 index 0000000..6b39ea9 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_card.scss @@ -0,0 +1,5 @@ +@use "../abstracts/mixins" as *; + +.card { + @include card; +} diff --git a/modules/amezing/sass/citrine/components/_crt.scss b/modules/amezing/sass/citrine/components/_crt.scss new file mode 100644 index 0000000..5ea199c --- /dev/null +++ b/modules/amezing/sass/citrine/components/_crt.scss @@ -0,0 +1,90 @@ +.crt, +pre:has(code[data-crt], code[data-lang=""]) { + position: relative; + animation: flicker 0.25s alternate infinite; + margin-inline: auto; + box-shadow: var(--shadow-glow); + background-image: var(--crt-bg); + background-color: transparent !important; + padding: 0; + width: fit-content; + max-width: 100%; + overflow: hidden; + color: var(--accent-color) !important; + font-weight: var(--font-weight-medium); + text-shadow: var(--text-shadow-glow); + + @keyframes flicker { + 25% { + opacity: 0.95; + } + + 50% { + opacity: 0.85; + } + + 75% { + opacity: 1; + } + + to { + opacity: 0.9; + } + } + + &::before, + &::after { + position: absolute; + inset: 0; + content: ""; + } + + &::before { + opacity: var(--scanlines-opacity); + z-index: 1; + animation: scanlines 0.1s linear infinite; + background-image: repeating-linear-gradient( + to bottom, + rgba(0, 0, 0, 0.25), + rgba(0, 0, 0, 0.25) 0.125rem, + transparent 0.125rem, + transparent 0.25rem + ); + background-position-y: -0.125rem; + background-repeat: no-repeat; + pointer-events: none; + + @keyframes scanlines { + to { + background-position-y: 0.125rem; + } + } + } + + &::after { + opacity: 0.05; + animation: scanline 5s linear infinite; + background-image: linear-gradient( + to bottom, + transparent, + var(--accent-color) 16rem + ); + background-position-y: -16rem; + background-size: 100% 16rem; + pointer-events: none; + + @keyframes scanline { + to { + background-position-y: calc(100% + 16rem); + } + } + } + + & > code { + display: block; + border-radius: inherit; + padding: 0.75rem 1rem; + overflow-x: auto; + scrollbar-width: thin; + } +} diff --git a/modules/amezing/sass/citrine/components/_external.scss b/modules/amezing/sass/citrine/components/_external.scss new file mode 100644 index 0000000..494f317 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_external.scss @@ -0,0 +1,12 @@ +@use "../abstracts/functions" as *; +@use "../abstracts/mixins" as *; + +a.external:not(:has(img, svg, video, picture, figure))::after { + @include icon(pseudo); + --icon: var(--icon-arrow-square-out); + display: inline-block; + vertical-align: -0.125rem; + opacity: var(--dim-opacity); + margin-inline-start: 0.125rem; + font-size: max(1rem, 1em); +} diff --git a/modules/amezing/sass/citrine/components/_iframe.scss b/modules/amezing/sass/citrine/components/_iframe.scss new file mode 100644 index 0000000..2334efe --- /dev/null +++ b/modules/amezing/sass/citrine/components/_iframe.scss @@ -0,0 +1,20 @@ +iframe { + display: block; + margin: 1rem auto; + box-shadow: var(--edge-highlight), var(--shadow); + border: none; + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + width: 100%; + max-width: 100%; + + &:fullscreen { + box-shadow: none; + border-radius: 0; + } + + &:-webkit-full-screen { + box-shadow: none; + border-radius: 0; + } +} diff --git a/modules/amezing/sass/citrine/components/_input.scss b/modules/amezing/sass/citrine/components/_input.scss new file mode 100644 index 0000000..d696a13 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_input.scss @@ -0,0 +1,261 @@ +@use "../abstracts/mixins" as *; + +input { + appearance: none; + background-color: var(--bg-muted-1); +} + +input:not([type]), +input[type="text"], +input[type="search"], +input[type="password"], +input[type="submit"] { + box-shadow: var(--edge-highlight); + border: none; + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + font-size: inherit; + line-height: inherit; + font-family: inherit; +} + +input:not([type]), +input[type="text"], +input[type="search"], +input[type="password"] { + padding: 0.5rem 0.75rem; + width: 100%; + + &::placeholder { + opacity: 1; + color: var(--fg-muted-1); + } +} + +input[type="submit"] { + transition: + background-color var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner); + padding: 0.375rem 0.75rem; + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-small); + + &:enabled:hover { + background-color: var(--bg-muted-2); + } + + &:enabled:active { + transform: var(--active); + } +} + +input[type="radio"], +input[type="checkbox"], +input[type="color"] { + position: relative; + transition: background-color var(--transition); + border: 0.125rem solid var(--bg-muted-2); + width: 1rem; + height: 1rem; + + &:not(:checked):enabled:hover { + background-color: var(--bg-muted-2); + } +} + +input[type="radio"], +input[type="checkbox"] { + vertical-align: -0.125em; + margin-inline-end: 0.25rem; + + &::before { + display: block; + position: absolute; + transform: scale(0.5); + opacity: 0; + transition: + opacity var(--transition), + transform var(--transition-bezier); + content: ""; + } + + &:checked { + border: 0.15rem solid transparent; + background-color: var(--accent-color); + + &::before { + transform: scale(1); + opacity: 1; + } + } +} + +input[type="radio"] { + border-radius: 50%; + + &::before { + inset-block-start: 0.125rem; + inset-inline-start: 0.125rem; + border-radius: 50%; + background-color: var(--contrast-color); + width: 0.5rem; + height: 0.5rem; + } +} + +input[type="checkbox"] { + border-radius: calc(var(--rounded-corner-small) / 2); + + &::before { + @include icon(pseudo); + --icon: var(--icon-check); + transform-origin: bottom left; + inset-block-start: -0.125rem; + inset-inline-start: -0.125rem; + color: var(--contrast-color); + font-size: 1rem; + } + + &.switch { + vertical-align: -0.375rem; + box-shadow: var(--edge-highlight); + border: none; + border-radius: 9999px; + width: 2.5rem; + height: 1.5rem; + + &.big { + vertical-align: -0.625rem; + width: 3rem; + height: 2rem; + + &::before { + width: 1.5rem; + height: 1.5rem; + } + } + + &::before { + transform: none; + transform-origin: center; + opacity: 1; + mask-image: none; + inset-block-start: 0.25rem; + inset-inline-start: 0.25rem; + box-shadow: var(--shadow); + border-radius: 50%; + background-color: white; + width: 1rem; + height: 1rem; + content: ""; + } + + &:checked { + background-color: var(--accent-color); + + &::before { + transform: translateX(1rem); + background-color: var(--contrast-color); + + :root[dir*="rtl"] & { + transform: translateX(-1rem); + } + } + } + + &:disabled::before { + box-shadow: none; + } + } +} + +input[type="color"] { + vertical-align: -0.375em; + box-shadow: var(--edge-highlight); + border: none; + border-radius: var(--rounded-corner-small); + padding: 0.25rem; + width: 3rem; + height: 2rem; + + &::-moz-color-swatch { + border: none; + border-radius: calc(var(--rounded-corner-small) - 0.25rem); + } + + &::-webkit-color-swatch-wrapper { + padding: 0; + } + + &::-webkit-color-swatch { + border-radius: calc(var(--rounded-corner-small) - 0.25rem); + } +} + +input[type="range"] { + appearance: none; + box-shadow: var(--edge-highlight); + border-radius: 9999px; + background: var(--bg-muted-1); + padding: 0.5rem 0.25rem; + width: 100%; + height: 1rem; + + &::-webkit-slider-runnable-track { + cursor: pointer; + border-radius: 9999px; + background: var(--accent-color); + width: 100%; + } + + &::-moz-range-track { + cursor: pointer; + border-radius: 9999px; + background: var(--accent-color); + width: 100%; + height: 0.5rem; + } + + &::-webkit-slider-thumb { + appearance: none; + cursor: ew-resize; + margin-block: -0.5rem; + box-shadow: var(--shadow); + border: none; + border-radius: 0.25rem; + background-color: white; + width: 0.5rem; + height: 1.5rem; + } + + &::-moz-range-thumb { + appearance: none; + cursor: ew-resize; + margin-block: -0.5rem; + box-shadow: var(--shadow); + border: none; + border-radius: 0.25rem; + background-color: white; + width: 0.5rem; + height: 1.5rem; + } + + &.hollow { + &::-webkit-slider-thumb { + margin-block: 0; + outline: 0.25rem solid white; + background-color: transparent; + height: 0.5rem; + } + + &::-moz-range-thumb { + margin-block: 0; + outline: 0.25rem solid white; + background-color: transparent; + height: 0.5rem; + } + } +} diff --git a/modules/amezing/sass/citrine/components/_media.scss b/modules/amezing/sass/citrine/components/_media.scss new file mode 100644 index 0000000..3409071 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_media.scss @@ -0,0 +1,165 @@ +img, +video { + display: block; + margin: 1rem auto; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + max-width: 100%; + max-height: calc(100vh - 2rem); + + &.full, + &[src*="#full"] { + width: 100%; + } + + &.start, + &.end, + &[src*="#start"], + &[src*="#end"] { + width: calc(var(--content-width) / 3); + } + + &.start, + &[src*="#start"] { + float: inline-start; + transform-origin: left; + margin-inline-end: 1rem; + + :root[dir*="rtl"] & { + transform-origin: right; + } + } + + &.end, + &[src*="#end"] { + float: inline-end; + transform-origin: right; + margin-inline-start: 1rem; + + :root[dir*="rtl"] & { + transform-origin: left; + } + } + + &.pixels, + &[src*="#pixels"] { + image-rendering: pixelated; + } + + &.has-alpha, + &[src*="#has-alpha"] { + box-shadow: none; + border-radius: 0; + background-color: transparent; + } + + &.drop-shadow, + &[src*="#drop-shadow"] { + filter: var(--drop-shadow); + } + + &.spoiler, + &[src*="#spoiler"] { + opacity: var(--dim-opacity); + clip-path: inset(0 0 0 0 round var(--rounded-corner)); + filter: blur(1.5rem); + + &:hover, + &:active { + opacity: 1; + clip-path: inset( + -0.75rem -0.75rem -0.75rem -0.75rem round var(--rounded-corner-small) + ); + filter: none; + } + + &.solid, + &[src*="#solid"] { + clip-path: none; + filter: brightness(0) contrast(0.5); + box-shadow: none; + + &:hover, + &:active { + filter: none; + } + } + } + + @media only screen and (max-width: 720px) { + &.start, + &.end, + &[src*="#start"], + &[src*="#end"] { + float: none; + margin-inline-start: auto; + margin-inline-end: auto; + width: auto; + } + } +} + +img { + transition: + box-shadow var(--transition), + border-radius var(--transition), + transform var(--transition-bezier); + + &:not(.no-hover, [src*="#no-hover"], .emoji) { + cursor: zoom-in; + + &:hover { + position: relative; + transform: var(--hover); + z-index: 1; + box-shadow: var(--edge-highlight), var(--shadow-raised); + border-radius: var(--rounded-corner-small); + } + + &.start, + &.end, + &[src*="#start"], + &[src*="#end"] { + transition: transform var(--transition-bezier-longer); + + &:hover { + transform: scale(2); + } + + @media only screen and (max-width: 720px) { + transform-origin: center; + transition: transform var(--transition-bezier); + + &:hover { + transform: var(--hover); + } + } + } + + &.has-alpha, + &[src*="#has-alpha"] { + &:hover { + box-shadow: none; + } + } + } +} + +a img:not(.no-hover, [src*="#no-hover"]) { + cursor: pointer; +} + +video:fullscreen { + box-shadow: none; + border-radius: 0; +} + +video:-webkit-full-screen { + box-shadow: none; + border-radius: 0; +} + +picture { + display: inline-block; +} diff --git a/modules/amezing/sass/citrine/components/_overflow-auto.scss b/modules/amezing/sass/citrine/components/_overflow-auto.scss new file mode 100644 index 0000000..712ba4b --- /dev/null +++ b/modules/amezing/sass/citrine/components/_overflow-auto.scss @@ -0,0 +1,13 @@ +.overflow-auto { + overflow: auto; + + &.overshoot { + margin-block: -1rem; + padding-block: 1rem; + } + + &.overshoot-row { + margin-inline: -1rem; + padding-inline: 1rem; + } +} diff --git a/modules/amezing/sass/citrine/components/_overshoot.scss b/modules/amezing/sass/citrine/components/_overshoot.scss new file mode 100644 index 0000000..af8447f --- /dev/null +++ b/modules/amezing/sass/citrine/components/_overshoot.scss @@ -0,0 +1,23 @@ +.overshoot { + --mask: linear-gradient( + to bottom, + transparent, + black 1rem, + black calc(100% - 1rem), + transparent + ); + -webkit-mask-image: var(--mask); + mask-image: var(--mask); + + &-row { + --mask: linear-gradient( + to right, + transparent, + black 1rem, + black calc(100% - 1rem), + transparent + ); + -webkit-mask-image: var(--mask); + mask-image: var(--mask); + } +} diff --git a/modules/amezing/sass/citrine/components/_skip-to-content.scss b/modules/amezing/sass/citrine/components/_skip-to-content.scss new file mode 100644 index 0000000..1e217af --- /dev/null +++ b/modules/amezing/sass/citrine/components/_skip-to-content.scss @@ -0,0 +1,23 @@ +#skip-to-content { + position: absolute; + transform: translateX(-50%) translateY(calc(-100% - 1rem)); + z-index: 9999; + transition: + backdrop-filter var(--transition), + box-shadow var(--transition), + transform var(--transition-bezier); + inset-inline-start: 50vw; + border-radius: 9999px; + background-color: var(--glass-bg-1); + padding: 0.375rem 0.75rem; + width: max-content; + text-decoration: none; + + &:focus-visible, + &:focus { + -webkit-backdrop-filter: var(--blur); + transform: translateX(-50%) translateY(1rem); + backdrop-filter: var(--blur); + box-shadow: var(--edge-highlight), var(--shadow); + } +} diff --git a/modules/amezing/sass/citrine/components/_spoiler.scss b/modules/amezing/sass/citrine/components/_spoiler.scss new file mode 100644 index 0000000..3b3c91b --- /dev/null +++ b/modules/amezing/sass/citrine/components/_spoiler.scss @@ -0,0 +1,25 @@ +span.spoiler { + filter: blur(0.25rem); + transition: filter var(--transition); + + &:focus, + &:hover { + filter: none; + } + + &.solid { + filter: none; + transition: + background-color var(--transition), + color var(--transition); + border-radius: var(--rounded-corner-small); + background-color: var(--fg-muted-1); + color: transparent; + + &:focus, + &:hover { + background-color: transparent; + color: inherit; + } + } +} diff --git a/modules/amezing/sass/citrine/components/_table.scss b/modules/amezing/sass/citrine/components/_table.scss new file mode 100644 index 0000000..569a914 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_table.scss @@ -0,0 +1,36 @@ +@use "../abstracts/functions" as *; + +table { + width: 100%; + overflow: hidden; +} + +thead { + border-block-start: none; + color: var(--fg-muted-2); + font-weight: var(--font-weight-semi-bold); +} + +tr { + &:nth-child(even) { + background-color: var(--bg-muted-1); + } + + th, + td { + padding: 0.5rem 1rem; + } +} + +tbody tr { + border-block: rpx(1) solid var(--bg-muted-2); + + &:last-child { + border-block-end: none; + } +} + +.overflow-auto table { + width: max-content; + min-width: 100%; +} diff --git a/modules/amezing/sass/citrine/components/_textarea.scss b/modules/amezing/sass/citrine/components/_textarea.scss new file mode 100644 index 0000000..58c355a --- /dev/null +++ b/modules/amezing/sass/citrine/components/_textarea.scss @@ -0,0 +1,17 @@ +textarea { + appearance: none; + box-shadow: var(--edge-highlight); + border: none; + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; + resize: none; + font-size: inherit; + line-height: inherit; + font-family: inherit; + + &::placeholder { + opacity: 1; + color: var(--fg-muted-1); + } +} diff --git a/modules/amezing/sass/citrine/components/_title.scss b/modules/amezing/sass/citrine/components/_title.scss new file mode 100644 index 0000000..16b8c89 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_title.scss @@ -0,0 +1,5 @@ +strong.title { + color: var(--accent-color); + font-size: var(--font-size-large); + line-height: 1; +} diff --git a/modules/amezing/sass/citrine/components/_tooltip.scss b/modules/amezing/sass/citrine/components/_tooltip.scss new file mode 100644 index 0000000..72aa4c1 --- /dev/null +++ b/modules/amezing/sass/citrine/components/_tooltip.scss @@ -0,0 +1,41 @@ +// attr() hack from https://cyrneko.eu/css-tricks.html +@media (pointer: coarse), (hover: none) { + .tooltip, + abbr[title] { + display: inline-flex; + position: relative; + justify-content: center; + + &::after { + position: absolute; + top: calc(100% + 0.25rem); + z-index: 9999; + backdrop-filter: var(--blur); + transition: + visibility var(--transition), + opacity var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner-small); + background-color: var(--glass-bg-1); + padding: 0.125rem 0.375rem; + width: max-content; + content: attr(title); + -webkit-backdrop-filter: var(--blur); + transform: translateY(-0.5rem) scale(0.5); + transform-origin: top; + visibility: hidden; + opacity: 0; + max-width: calc(var(--content-width) / 3); + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + &:focus::after, + &:hover::after { + transform: none; + visibility: visible; + opacity: 1; + } + } +} diff --git a/modules/amezing/sass/citrine/components/_visually-hidden.scss b/modules/amezing/sass/citrine/components/_visually-hidden.scss new file mode 100644 index 0000000..fbf292a --- /dev/null +++ b/modules/amezing/sass/citrine/components/_visually-hidden.scss @@ -0,0 +1,11 @@ +.visually-hidden { + position: absolute !important; + clip-path: inset(50%) !important; + margin: -1px !important; + border-width: 0 !important; + padding: 0 !important; + width: 1px !important; + height: 1px !important; + overflow: hidden !important; + white-space: nowrap !important; +} diff --git a/modules/amezing/sass/citrine/style.scss b/modules/amezing/sass/citrine/style.scss new file mode 100644 index 0000000..77ddca1 --- /dev/null +++ b/modules/amezing/sass/citrine/style.scss @@ -0,0 +1,29 @@ +/* +* Citrine v1.0.0 (https://citrine.daudix.one) +* Copyright 2026 David "Daudix" Lapshin and contributors +* Licensed under MIT (https://codeberg.org/daudix/citrine/raw/branch/main/LICENSE.txt) +*/ + +@use "base/root"; +@use "base/reset"; +@use "base/animations"; +@use "base/base"; +@use "base/layout"; +@use "base/typography"; + +@use "components/buttons"; +@use "components/card"; +@use "components/crt"; +@use "components/external"; +@use "components/iframe"; +@use "components/input"; +@use "components/media"; +@use "components/overflow-auto"; +@use "components/overshoot"; +@use "components/skip-to-content"; +@use "components/spoiler"; +@use "components/table"; +@use "components/textarea"; +@use "components/title"; +@use "components/tooltip"; +@use "components/visually-hidden"; diff --git a/modules/amezing/sass/components/_alerts.scss b/modules/amezing/sass/components/_alerts.scss new file mode 100644 index 0000000..d9f5ed5 --- /dev/null +++ b/modules/amezing/sass/components/_alerts.scss @@ -0,0 +1,153 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; +@use "../citrine/abstracts/variables" as *; + +[class^="markdown-alert"] { + display: flow-root; + position: relative; + + &::before { + margin-inline-start: 1.5rem; + content: var(--alert-title); + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + } + + &::after { + @include icon(pseudo); + --icon: var(--alert-icon); + position: absolute; + margin-inline-end: 0.25rem; + inset-block-start: rpx(1); + inset-inline-start: 0.75rem; + color: var(--accent-color); + font-size: 1.25rem; + } + + & > :first-child { + margin-block-start: 0.25rem; + } + + & > :last-child { + margin-block-end: 0; + } +} + +.markdown-alert { + // GitHub flavored alerts + &-note, + &-tip, + &-important, + &-warning, + &-caution, + // Generic colors + &-red, + &-orange, + &-yellow, + &-green, + &-blue, + &-purple { + @include variables-accent(("light", "common")); + + @media (prefers-color-scheme: dark) { + @include variables-accent("dark"); + } + } + + &-note { + &::before { + content: var(--alert-note-title); + } + + &::after { + --icon: var(--icon-info); + } + } + + &-tip { + &::before { + content: var(--alert-tip-title); + } + + &::after { + --icon: var(--icon-lightbulb); + } + } + + &-important { + &::before { + content: var(--alert-important-title); + } + + &::after { + --icon: var(--icon-warning-diamond); + } + } + + &-warning { + &::before { + content: var(--alert-warning-title); + } + + &::after { + --icon: var(--icon-warning); + } + } + + &-caution { + &::before { + content: var(--alert-caution-title); + } + + &::after { + --icon: var(--icon-warning-octagon); + } + } + + &-caution, + &-red { + --accent-h: var(--red-h); + --accent-s: var(--red-s); + --accent-l: var(--red-l); + --accent-color: var(--red-fg); + } + + &-orange { + --accent-h: var(--orange-h); + --accent-s: var(--orange-s); + --accent-l: var(--orange-l); + --accent-color: var(--orange-fg); + } + + &-warning, + &-yellow { + --accent-h: var(--yellow-h); + --accent-s: var(--yellow-s); + --accent-l: var(--yellow-l); + --accent-color: var(--yellow-fg); + } + + &-tip, + &-green { + --accent-h: var(--green-h); + --accent-s: var(--green-s); + --accent-l: var(--green-l); + --accent-color: var(--green-fg); + } + + &-note, + &-blue { + --accent-h: var(--blue-h); + --accent-s: var(--blue-s); + --accent-l: var(--blue-l); + --accent-color: var(--blue-fg); + } + + &-important, + &-purple { + --accent-h: var(--purple-h); + --accent-s: var(--purple-s); + --accent-l: var(--purple-l); + --accent-color: var(--purple-fg); + } +} diff --git a/modules/amezing/sass/components/_article-details.scss b/modules/amezing/sass/components/_article-details.scss new file mode 100644 index 0000000..b3ca1ff --- /dev/null +++ b/modules/amezing/sass/components/_article-details.scss @@ -0,0 +1,71 @@ +@use "../citrine/abstracts/functions" as *; + +.article-details { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-block-end: 2rem; + color: var(--fg-muted-2); + font-size: var(--font-size-small); + + .items { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 0.25rem 0.5rem; + } + + .item { + display: inline-flex; + align-items: center; + gap: 0.375rem; + border-radius: var(--rounded-corner-small); + } + + a { + color: inherit; + font-weight: var(--font-weight-normal); + font-size: var(--font-size-small); + text-decoration-color: transparent; + + &:hover { + text-decoration-style: solid; + text-decoration-color: var(--fg-muted-1); + } + } + + .tooltip { + cursor: help; + } + + .icon { + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding: 0.375rem; + color: var(--accent-color); + font-size: 1.25rem; + } + + .tags { + display: flex; + flex-wrap: wrap; + gap: 0 0.5rem; + margin: 0; + border-block-start: rpx(1) solid var(--bg-muted-2); + padding-inline-start: 0; + padding-block-start: 0.5rem; + width: 100%; + list-style: none; + + li { + display: flex; + margin-block: 0; + padding: 0; + } + + a::before { + content: "#"; + } + } +} diff --git a/modules/amezing/sass/components/_article-list.scss b/modules/amezing/sass/components/_article-list.scss new file mode 100644 index 0000000..e597ecc --- /dev/null +++ b/modules/amezing/sass/components/_article-list.scss @@ -0,0 +1,254 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/variables" as *; +@use "../citrine/abstracts/mixins" as *; + +.article-list { + display: grid; + grid-template-columns: repeat( + auto-fill, + minmax(calc(var(--content-width) / 2.5), 1fr) + ); + gap: 1rem; + margin-block-start: 2rem; + + &.categories article { + position: relative; + + &:hover .icon:last-child { + transform: translateY(-50%) rotate(-10deg) scale(1.5); + } + + .icon:last-child { + position: absolute; + transform: translateY(-50%); + z-index: -1; + transition: transform var(--transition-bezier); + inset-block-start: 50%; + inset-inline-end: -3rem; + color: var(--bg-muted-1); + font-size: 12rem; + } + } + + article { + display: flex; + position: relative; + flex-direction: column; + justify-content: space-between; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: 1.25rem; + background-color: var(--bg-muted-1); + padding: 0; + overflow: hidden; + + &.draft { + opacity: var(--disabled-opacity); + filter: grayscale(1); + transition-duration: var(--transition); + transition-property: opacity, filter; + + &:hover { + opacity: 1; + filter: none; + } + } + + &.has-banner.has-accent-color { + background-image: var(--bg-overlay), var(--blurnail); + background-position: center; + background-size: cover; + } + + &.has-accent-color { + @include variables-accent(("light", "common")); + + @media (prefers-color-scheme: dark) { + & { + @include variables-accent("dark"); + } + } + } + + & > div { + display: flex; + flex: 1; + flex-direction: column; + margin: 1rem; + + & > a { + text-decoration: none; + } + } + + h3 { + margin: 0; + border-inline-start: 0.25rem solid; + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; + padding-inline-start: 0.5rem; + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + text-decoration: none; + + &:hover::after { + opacity: 1; + } + + &::after { + opacity: 0; + transition: opacity var(--transition); + content: " →"; + } + } + + .description p { + margin-block-end: 0.75rem; + } + + .open-post { + @include easing-mask(bottom); + display: flex; + position: relative; + justify-content: center; + align-items: center; + margin-block-end: -2rem; + box-shadow: var(--edge-highlight); + border-radius: 0; + aspect-ratio: 2 / 1; + overflow: hidden; + text-decoration: none; + + &:hover { + &::before { + background-color: var(--shade-color); + } + + img { + transform: var(--hover); + filter: blur(0.25rem); + } + + .icon { + transform: none; + opacity: 1; + } + } + + &:active img { + transform: none; + } + + &::before { + position: absolute; + z-index: 1; + transition: background-color var(--transition); + inset: 0; + border-radius: inherit; + content: ""; + } + + img { + position: absolute; + transition-duration: var(--transition); + transition-property: transform, filter; + margin: 0; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + } + + .icon { + transform: scale(1.5); + opacity: 0; + z-index: 1; + transition: + transform var(--transition-bezier), + opacity var(--transition); + color: white; + font-size: 1.25rem; + } + } + + .article-details { + flex: 1; + justify-content: flex-end; + margin-block-end: 0; + + .tags { + margin-block-start: auto; + margin-block-end: -0.25rem; + } + } + } +} + +.paginator { + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; + margin-block-start: 4rem; + + &:has(a.paginator-item.previous:active) .paginator-counter { + border-start-start-radius: 1rem; + border-end-start-radius: 1rem; + } + + &:has(a.paginator-item.next:active) .paginator-counter { + border-start-end-radius: 1rem; + border-end-end-radius: 1rem; + } + + &-item { + display: inline-block; + transition: + border-radius var(--transition), + border-start-end-radius var(--transition), + border-end-end-radius var(--transition), + border-start-start-radius var(--transition), + border-end-start-radius var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: 1rem; + background-color: var(--bg-muted-1); + padding: 0.5rem; + color: var(--accent-color); + line-height: 0; + text-decoration: none; + + &:not([aria-disabled="true"]):hover { + background-color: var(--bg-muted-2); + } + + &:not([aria-disabled="true"]):active { + transform: var(--active); + border-radius: 1rem; + } + + &-previous { + border-start-end-radius: var(--rounded-corner-small); + border-end-end-radius: var(--rounded-corner-small); + } + + &-next { + border-start-start-radius: var(--rounded-corner-small); + border-end-start-radius: var(--rounded-corner-small); + } + } + + &-counter { + @include gradient-fill; + display: inline-block; + transition: var(--transition-bezier); + transition-property: + border-start-start-radius, border-end-start-radius, + border-start-end-radius, border-end-end-radius; + border-radius: var(--rounded-corner-small); + background-color: var(--bg-muted-1); + padding: 0.25rem 0.5rem; + font-weight: var(--font-weight-semi-bold); + font-variant-numeric: tabular-nums; + } +} diff --git a/modules/amezing/sass/components/_article-nav.scss b/modules/amezing/sass/components/_article-nav.scss new file mode 100644 index 0000000..217e350 --- /dev/null +++ b/modules/amezing/sass/components/_article-nav.scss @@ -0,0 +1,66 @@ +.article-nav { + display: flex; + gap: 0.25rem; + + @media only screen and (max-width: 720px) { + flex-direction: column; + } + + &-item { + flex: 1; + transition: + box-shadow var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + border-radius: var(--rounded-corner); + padding: 0.75rem 1rem; + min-width: 0; + text-decoration: none; + + &:hover { + box-shadow: var(--edge-highlight), var(--shadow); + background-color: var(--bg-muted-1); + + .article-title { + color: var(--accent-color); + } + } + + &:active { + transform: var(--active); + } + + &.prev { + text-align: start; + + .nav-arrow::before { + content: "← "; + } + } + + &.next { + text-align: end; + + .nav-arrow::after { + content: " →"; + } + } + } + + .nav-arrow { + margin-block-end: 0.5rem; + color: var(--fg-muted-2); + font-weight: var(--font-weight-normal); + font-size: var(--font-size-small); + } + + .article-title { + display: block; + transition: color var(--transition); + max-width: 100%; + overflow: hidden; + color: var(--fg-muted-1); + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/modules/amezing/sass/components/_audio-button.scss b/modules/amezing/sass/components/_audio-button.scss new file mode 100644 index 0000000..1f4fe43 --- /dev/null +++ b/modules/amezing/sass/components/_audio-button.scss @@ -0,0 +1,42 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.audio-button { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 0.5rem; + font-size: var(--font-size-small); + font-weight: var(--font-weight-semi-bold); + color: var(--accent-color); + background-color: var(--accent-bg-1); + border: none; + cursor: pointer; + transition: + background-color var(--transition), + transform var(--transition-bezier), + box-shadow var(--transition); + + &:hover { + background-color: var(--accent-bg-2); + transform: scale(1.03); + } + + &:active { + transform: var(--active); + } + + &:focus-visible { + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.25rem; + } + + .audio-button-label { + display: none; + + @media (min-width: 480px) { + display: inline; + } + } +} diff --git a/modules/amezing/sass/components/_audio.scss b/modules/amezing/sass/components/_audio.scss new file mode 100644 index 0000000..18d8397 --- /dev/null +++ b/modules/amezing/sass/components/_audio.scss @@ -0,0 +1,41 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +// Who doesn't like a party popper mid article? +.audio { + @include chip; + padding-block: 0; + color: var(--accent-color); + font-size: var(--font-size-medium); + + &:hover { + color: var(--accent-color) !important; + + .icon { + animation: speaker var(--transition-long) ease-out infinite; + + @keyframes speaker { + from { + --icon: var(--icon-speaker-none); + } + + 50% { + --icon: var(--icon-speaker-low); + } + + to { + --icon: var(--icon-speaker-high); + } + } + } + } + + &:active { + transform: var(--active); + } + + .icon { + vertical-align: rpx(-3); + margin-inline-start: 0.25rem; + } +} diff --git a/modules/amezing/sass/components/_banner.scss b/modules/amezing/sass/components/_banner.scss new file mode 100644 index 0000000..e261d07 --- /dev/null +++ b/modules/amezing/sass/components/_banner.scss @@ -0,0 +1,98 @@ +@use "../citrine/abstracts/functions" as *; + +body.has-banner { + background-image: var(--bg-overlay), var(--blurnail); + background-position: center; + background-size: cover; + background-attachment: fixed; +} + +#banner { + --mask: linear-gradient(black calc(100% - 2rem), transparent); + -webkit-mask-image: var(--mask); + display: flex; + grid-area: banner; + justify-content: center; + mask-image: var(--mask); + margin-block-end: -2.5rem; + padding-block: 3.25rem 2.5rem; + width: 100%; + + div { + position: relative; + margin-inline: 1rem; + aspect-ratio: 2 / 1; + width: 100%; + max-width: var(--content-width); + } + + img { + position: absolute; + margin: 0; + inset: 0; + border-radius: var(--rounded-corner) var(--rounded-corner) 0 0 !important; + aspect-ratio: 2 / 1; + width: 100%; + object-fit: cover; + } + + img:nth-of-type(1) { + opacity: var(--dim-opacity); + z-index: -1; + filter: blur(1.5rem); + animation: banner-glow-load calc(0.525s + var(--transition-long)); + inset-block-start: 1rem; + + @keyframes banner-glow-load { + from, + 52.5% { + opacity: 0; + } + } + } + + img:nth-of-type(2) { + transform-origin: bottom; + animation: banner-load calc(0.525s + var(--transition-longer)); + + @keyframes banner-load { + from, + 52.5% { + transform: translateY(2rem) scale(0.95); + opacity: 0; + } + } + } + + & + #main-content { + margin-block-start: 0; + + article > h1:first-of-type, + h1:first-of-type { + margin-inline: -1rem; + margin-block: 0; + border-radius: var(--rounded-corner) var(--rounded-corner) 0 0; + padding: 1rem; + width: unset; + -webkit-backdrop-filter: blur(1rem); + backdrop-filter: blur(1rem); + animation: banner-heading calc(0.525s + var(--transition-longer)); + box-shadow: inset 0 rpx(1) 0 var(--accent-highlight); + background-image: linear-gradient( + to bottom, + var(--bg-muted-1), + transparent 50% + ); + background-size: auto 200%; + text-decoration: none; + + @keyframes banner-heading { + from, + 52.5% { + box-shadow: none; + background-position-y: 100%; + } + } + } + } +} diff --git a/modules/amezing/sass/components/_comments.scss b/modules/amezing/sass/components/_comments.scss new file mode 100644 index 0000000..e5cc946 --- /dev/null +++ b/modules/amezing/sass/components/_comments.scss @@ -0,0 +1,459 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#comments { + #qrcode { + float: inline-end; + cursor: help; + box-sizing: content-box; + margin-inline-start: 1rem; + margin-block-start: 3rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: white; + padding: 0.75rem; + width: rpx(116); + height: rpx(116); + + svg { + pointer-events: none; + } + + @media only screen and (max-width: 720px) { + display: none; + } + } + + #comments-wrapper { + display: flex; + flex-direction: column; + gap: 2rem; + margin-block-start: 2rem; + + #comments-status { + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-x-large); + text-align: center; + } + + .comment { + display: grid; + // grid-template-columns: min-content; + grid-template-columns: auto 1fr; + grid-template-areas: + "avatar header " + "...... post " + "...... media " + "...... card " + "...... interactions"; + column-gap: 1rem; + justify-items: start; + animation: slide-in-right var(--transition-bezier); + + :root[dir*="rtl"] & { + animation: slide-in-left var(--transition-bezier); + } + + @media only screen and (max-width: 720px) { + grid-template-areas: + "avatar header " + "post post " + "media media " + "card card " + "interactions interactions"; + align-items: start; + } + + &.comment-reply { + position: relative; + border-inline-start: 0.25rem solid var(--bg-muted-2); + border-start-start-radius: 0.25rem; + border-end-start-radius: 0.25rem; + padding-inline-start: 1rem; + + &:has(+ .comment-reply) { + border-end-start-radius: 0; + } + + & + .comment-reply { + margin-block-start: -2rem; + border-start-start-radius: 0; + padding-block-start: 2rem; + } + } + + .avatar-link { + grid-area: avatar; + width: 4rem; + height: 4rem; + + @media only screen and (max-width: 480px) { + width: 3rem; + height: 3rem; + } + + .avatar { + transition: transform var(--transition-bezier); + margin: 0; + background-size: cover; + width: 100%; + height: 100%; + + &:hover { + transform: rotate(10deg) var(--hover); + border-radius: var(--rounded-corner); + } + + &:active { + transform: var(--active); + } + } + } + + .mention { + @include chip; + display: inline-block; + + &:hover { + background-color: var(--bg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + .invisible { + display: none; + } + + .ellipsis::after { + content: "…"; + } + + header { + display: flex; + grid-area: header; + flex-direction: column; + width: 100%; + min-width: 0; + color: var(--fg-muted-1); + line-height: 1; + + .display, + .instance { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .display { + color: var(--fg-color); + line-height: 1.5rem; + text-decoration: none; + + .emoji { + cursor: unset; + + &:hover { + transform: none; + } + } + } + + .instance { + margin-block-start: 0.25rem; + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-x-small); + + &.op { + cursor: help; + + &::before { + @include icon(pseudo); + --icon: var(--icon-seal-check); + display: inline-block; + vertical-align: text-top; + margin-inline-end: 0.25rem; + font-size: 1rem; + } + } + } + + .timestamp { + margin-block-start: 0.25rem; + + a { + color: inherit; + font-size: var(--font-size-x-small); + text-decoration-color: transparent; + + &:hover { + text-decoration-style: solid; + text-decoration-color: inherit; + } + + &::after { + vertical-align: top; + } + } + } + } + + details { + border-radius: var(--rounded-corner-small); + background-image: + linear-gradient( + to right, + transparent, + transparent 0.5rem, + var(--bg-muted-1) 0.5rem, + var(--bg-muted-1) calc(100% - 0.5rem), + transparent calc(100% - 0.5rem), + transparent + ), + linear-gradient( + to right, + transparent, + transparent 0.5rem, + var(--bg-color) 0.5rem, + var(--bg-color) calc(100% - 0.5rem), + transparent calc(100% - 0.5rem), + transparent + ), + repeating-linear-gradient( + 45deg, + var(--contrast-color), + var(--contrast-color) 0.25rem, + var(--accent-color) 0.25rem, + var(--accent-color) 0.5rem + ); + width: 100%; + + summary { + border-radius: 0; + background-image: none; + } + } + + main { + grid-area: post; + justify-self: stretch; + margin-block-start: 1rem; + padding: 0; + + :first-child { + margin-block-start: 0; + } + + :last-child { + margin-block-end: 0; + } + } + + .attachments { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr)); + grid-area: media; + gap: 0.5rem; + margin-block-start: 1rem; + + img, + video { + margin: 0; + } + } + + .meta-card { + grid-area: card; + transition: transform var(--transition-bezier); + margin-block-start: 1rem; + width: min(calc(var(--content-width) / 2), 100%); + font-weight: var(--font-weight-normal); + text-decoration: none; + + &:hover { + img { + transform: var(--hover); + box-shadow: var(--edge-highlight), var(--shadow-raised); + border-radius: var(--rounded-corner-small); + } + + figcaption { + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-2); + } + } + + &:active { + transform: var(--active); + } + + figure { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0; + + img { + margin: 0; + border-radius: var(--rounded-corner) var(--rounded-corner) + var(--rounded-corner-small) var(--rounded-corner-small); + aspect-ratio: 16 / 9; + object-fit: cover; + + & + figcaption { + border-radius: var(--rounded-corner-small) + var(--rounded-corner-small) var(--rounded-corner) + var(--rounded-corner); + } + } + + figcaption { + display: flex; + flex-direction: column; + gap: 0.25rem; + transition-duration: var(--transition); + transition-property: border-radius, background-color; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 1rem; + color: var(--fg-color); + font-size: var(--font-size-medium); + text-align: start; + + p { + margin: 0; + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + } + } + } + + footer { + display: flex; + grid-area: interactions; + align-items: center; + gap: 0.25rem; + margin-block-start: 1rem; + width: 100%; + min-width: 0; + + @media only screen and (max-width: 480px) { + flex-wrap: wrap; + } + + .boosts, + .faves, + .reaction { + padding: 0.375rem 0.75rem; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + .boosts, + .faves { + transition: + box-shadow var(--transition), + border-radius var(--transition), + transform var(--transition-bezier); + border-radius: rpx(18); + text-decoration: none; + + .icon { + vertical-align: rpx(-2); + transition: transform var(--transition-bezier); + margin-inline-end: 0.25rem; + } + + &:hover { + box-shadow: var(--edge-highlight); + text-decoration: none; + } + + &:active { + transform: var(--active); + border-radius: rpx(18); + } + } + + .boosts { + border-start-end-radius: var(--rounded-corner-small); + border-end-end-radius: var(--rounded-corner-small); + padding-inline-end: 0.625rem; + color: var(--purple-fg); + + .icon { + --icon: var(--icon-repeat); + } + + &:hover { + background-color: var(--purple-bg-1); + + .icon { + transform: rotate(180deg); + } + } + } + + .faves { + border-start-start-radius: var(--rounded-corner-small); + border-end-start-radius: var(--rounded-corner-small); + padding-inline-start: 0.625rem; + color: var(--yellow-fg); + + .icon { + --icon: var(--icon-star); + } + + &:hover { + background-color: var(--yellow-bg-1); + + .icon { + transform: rotate(72deg); + } + } + } + + .reactions { + display: flex; + // flex-wrap: nowrap; + gap: 0.25rem; + margin: -0.5rem -1rem; + padding: 0.5rem 1rem; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + color: var(--accent-color); + font-weight: var(--font-weight-semi-bold); + + .reaction { + display: flex; + flex-shrink: 0; + align-items: center; + gap: 0.25rem; + cursor: zoom-in; + border: rpx(1) solid var(--bg-muted-2); + border-radius: rpx(19); + + &:hover .emoji { + transform: scale(2); + } + + .emoji { + cursor: unset; + } + + span { + font-variant-numeric: tabular-nums; + } + } + } + } + } + } +} diff --git a/modules/amezing/sass/components/_deanchor.scss b/modules/amezing/sass/components/_deanchor.scss new file mode 100644 index 0000000..1fd7c89 --- /dev/null +++ b/modules/amezing/sass/components/_deanchor.scss @@ -0,0 +1,20 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.deanchor { + display: inline; + font-family: var(--font-family-mono); + font-size: var(--font-size-small); + color: var(--fg-muted-2); + background-color: var(--bg-muted-1); + padding: 0.125rem 0.375rem; + border-radius: 0.25rem; + transition: + background-color var(--transition), + color var(--transition); + + &:hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-1); + } +} diff --git a/modules/amezing/sass/components/_emoji.scss b/modules/amezing/sass/components/_emoji.scss new file mode 100644 index 0000000..93570ae --- /dev/null +++ b/modules/amezing/sass/components/_emoji.scss @@ -0,0 +1,21 @@ +.emoji { + display: inline-block; + vertical-align: bottom; + transition: transform var(--transition-bezier); + cursor: zoom-in; + margin: 0; + box-shadow: none; + border-radius: 0; + background-color: transparent; + width: 1.5rem; + height: 1.5rem; + + &:hover { + transform: scale(2); + } + + &.big { + width: 3rem; + height: 3rem; + } +} diff --git a/modules/amezing/sass/components/_footnotes.scss b/modules/amezing/sass/components/_footnotes.scss new file mode 100644 index 0000000..c083897 --- /dev/null +++ b/modules/amezing/sass/components/_footnotes.scss @@ -0,0 +1,62 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.footnotes-list p { + margin-block: 0; +} + +.footnote-reference { + display: inline-flex; + vertical-align: baseline; + transition: + background-color var(--transition), + outline-offset calc(var(--transition-long) * 3), + transform var(--transition-bezier); + margin-inline-start: 0.25rem; + outline-offset: calc(var(--content-width) / 2) !important; + border-radius: 0.6875em; + background-color: var(--fg-muted-1); + // width: 1.375em; + line-height: 1.375em; + + &:hover { + background-color: var(--accent-color); + + a::after { + mask-position: center rpx(18); + } + } + + &:active { + transform: var(--active); + } + + &:target { + animation: none !important; + outline-offset: rpx(-3) !important; + background-color: var(--accent-color); + } + + a { + flex: 1; + border-radius: inherit; + padding-inline: rpx(9) rpx(6); + color: var(--bg-color); + font-size: var(--font-size-small); + font-variant-numeric: tabular-nums; + text-align: center; + text-decoration: none; + + &::after { + @include icon(pseudo); + --icon: var(--icon-caret-down); + display: inline-block; + vertical-align: rpx(-7); + mask-position: center; + mask-size: 1em; + transition: mask-position var(--transition); + margin-inline-start: 0.25rem; + height: rpx(22); + } + } +} diff --git a/modules/amezing/sass/components/_fuse.scss b/modules/amezing/sass/components/_fuse.scss new file mode 100644 index 0000000..494ef74 --- /dev/null +++ b/modules/amezing/sass/components/_fuse.scss @@ -0,0 +1,50 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.fuse { + display: flex; + flex-direction: column; + gap: 0.5rem; + max-width: 30rem; + + .fuse-input { + width: 100%; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + border: 1px solid var(--bg-muted-2); + background-color: var(--bg-muted-1); + color: var(--fg-color); + font-size: var(--font-size-medium); + transition: + border-color var(--transition), + box-shadow var(--transition); + + &:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px var(--accent-bg-1), 0 0 0 4px var(--accent-color); + } + } + + .fuse-results { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .fuse-result { + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background-color: var(--bg-muted-1); + text-decoration: none; + color: var(--fg-color); + transition: + background-color var(--transition), + padding-left var(--transition); + + &:hover { + background-color: var(--bg-muted-2); + padding-left: 1rem; + } + } + } +} diff --git a/modules/amezing/sass/components/_giallo.scss b/modules/amezing/sass/components/_giallo.scss new file mode 100644 index 0000000..0c228b4 --- /dev/null +++ b/modules/amezing/sass/components/_giallo.scss @@ -0,0 +1,16 @@ +.giallo:has(.giallo-ln) { + padding-inline: 0; +} + +.giallo-l { + display: inline-block; + width: 100%; +} + +.giallo-ln { + display: inline-block; + margin-right: 1ch; + min-width: 3ch; + user-select: none; + text-align: right; +} diff --git a/modules/amezing/sass/components/_icon.scss b/modules/amezing/sass/components/_icon.scss new file mode 100644 index 0000000..6768a12 --- /dev/null +++ b/modules/amezing/sass/components/_icon.scss @@ -0,0 +1,9 @@ +@use "../citrine/abstracts/mixins" as *; + +.icon { + @include icon; + + &::before { + @include icon(pseudo); + } +} diff --git a/modules/amezing/sass/components/_iframe.scss b/modules/amezing/sass/components/_iframe.scss new file mode 100644 index 0000000..550da8d --- /dev/null +++ b/modules/amezing/sass/components/_iframe.scss @@ -0,0 +1,6 @@ +iframe { + &.vimeo-embed, + &.youtube-embed { + aspect-ratio: 16 / 9; + } +} diff --git a/modules/amezing/sass/components/_index.scss b/modules/amezing/sass/components/_index.scss new file mode 100644 index 0000000..61889d1 --- /dev/null +++ b/modules/amezing/sass/components/_index.scss @@ -0,0 +1,25 @@ +@use "alerts"; +@use "article-details"; +@use "article-list"; +@use "article-nav"; +@use "audio"; +@use "banner"; +@use "comments"; +@use "emoji"; +@use "footnotes"; +@use "giallo"; +@use "icon"; +@use "iframe"; +@use "marquee"; +@use "not-found"; +@use "pre-container"; +@use "search-modal"; +@use "skip-to-content"; +@use "statements"; +@use "tag-chips"; +@use "zola-anchor"; +@use "zola-error"; +@use "audio-button"; +@use "deanchor"; +@use "fuse"; +@use "search-fuse"; \ No newline at end of file diff --git a/modules/amezing/sass/components/_marquee.scss b/modules/amezing/sass/components/_marquee.scss new file mode 100644 index 0000000..e995c33 --- /dev/null +++ b/modules/amezing/sass/components/_marquee.scss @@ -0,0 +1,35 @@ +.marquee { + margin-inline: -1rem; + padding-inline: 1rem; + overflow: hidden; + + &.overshoot-row > * { + animation: marquee-scroll + calc((var(--text-width) + var(--container-width)) / 40 * 1s) linear + infinite; + + @keyframes marquee-scroll { + 0%, + 10% { + transform: translateX(0); + } + + 45%, + 55% { + transform: translateX( + calc((var(--container-width) - var(--text-width)) * 1px) + ); + } + + 90%, + 100% { + transform: translateX(0); + } + } + } + + & > * { + display: inline-block; + white-space: nowrap; + } +} diff --git a/modules/amezing/sass/components/_not-found.scss b/modules/amezing/sass/components/_not-found.scss new file mode 100644 index 0000000..34b92bf --- /dev/null +++ b/modules/amezing/sass/components/_not-found.scss @@ -0,0 +1,11 @@ +picture#not-found { + display: block; + + img { + width: 8rem; + } +} + +img#not-found { + width: 8rem; +} diff --git a/modules/amezing/sass/components/_pre-container.scss b/modules/amezing/sass/components/_pre-container.scss new file mode 100644 index 0000000..d011d77 --- /dev/null +++ b/modules/amezing/sass/components/_pre-container.scss @@ -0,0 +1,141 @@ +@use "../citrine/abstracts/functions" as *; + +.pre-container { + position: relative; + margin: 1rem 0 1rem; + box-shadow: var(--shadow); + border-radius: var(--rounded-corner); + + &::after { + position: absolute; + inset: 0; + box-shadow: var(--edge-highlight); + border-radius: inherit; + pointer-events: none; + content: ""; + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + transition: border-block-end-color var(--transition); + border-radius: var(--rounded-corner) var(--rounded-corner) 0 0; + border-block-end: rpx(1) solid transparent; + background-image: linear-gradient( + to right, + transparent 50%, + var(--bg-muted-neutral-1) 75%, + transparent 100% + ); + background-size: 200%; + padding: 0.25rem; + + &:not(.z-l-code), + &:not(.z-d-code) { + background-color: var(--bg-muted-1); + color: var(--accent-color); + + button:hover { + background-color: var(--bg-muted-1); + } + + &.active { + border-block-end-color: var(--bg-muted-1); + + button { + background-color: var(--bg-muted-1); + } + } + } + + span { + margin-inline-start: 0.75rem; + font-weight: var(--font-weight-semi-bold); + } + + button { + position: relative; + transition: + box-shadow var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + cursor: pointer; + box-shadow: none; + border-radius: var(--rounded-corner-small); + background-color: transparent; + padding: 0.5rem; + color: inherit; + line-height: 0; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-neutral-1); + } + + &:active { + transform: var(--active); + } + + &:disabled { + cursor: not-allowed; + + &:active { + transform: none; + } + } + + .icon { + transition: + opacity var(--transition-longer), + transform var(--transition-bezier-longer); + font-size: 1rem; + + &.copy { + --icon: var(--icon-copy); + transform-origin: bottom left; + } + + &.check { + --icon: var(--icon-check-square-offset); + position: absolute; + transform: scale(0.5); + transform-origin: top right; + opacity: 0; + inset: 0.5rem; + } + } + } + + &.active { + animation: active-shimmer var(--transition-long); + border-block-end-color: var(--bg-muted-neutral-1); + + button { + opacity: 1; + + .icon.copy { + transform: scale(0.5); + opacity: 0; + } + + .icon.check { + transform: none; + opacity: 1; + } + } + + @keyframes active-shimmer { + to { + background-position-x: -200%; + } + } + } + } + + pre { + margin: 0; + box-shadow: none; + border-radius: 0 0 var(--rounded-corner) var(--rounded-corner); + } +} diff --git a/modules/amezing/sass/components/_search-fuse.scss b/modules/amezing/sass/components/_search-fuse.scss new file mode 100644 index 0000000..20bb5c2 --- /dev/null +++ b/modules/amezing/sass/components/_search-fuse.scss @@ -0,0 +1,73 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +.search-fuse { + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 40rem; + margin: 0 auto; + + &-input-wrap { + position: relative; + display: flex; + align-items: center; + } + + &-icon { + position: absolute; + left: 0.75rem; + color: var(--fg-muted-2); + pointer-events: none; + transition: + color var(--transition), + opacity var(--transition-bezier); + opacity: 0.7; + } + + &-input { + width: 100%; + padding: 0.75rem 1rem 0.75rem 2.5rem; + border-radius: 0.5rem; + border: 1px solid var(--bg-muted-2); + background-color: var(--bg-muted-1); + color: var(--fg-color); + font-size: var(--font-size-medium); + transition: + border-color var(--transition), + box-shadow var(--transition), + background-color var(--transition); + + &:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px var(--accent-bg-1), 0 0 0 4px var(--accent-color); + } + + &::placeholder { + color: var(--fg-muted-2); + } + } + + &-results { + display: flex; + flex-direction: column; + gap: 0.25rem; + + .search-fuse-result { + padding: 0.5rem 0.75rem; + border-radius: 0.25rem; + background-color: var(--bg-muted-1); + text-decoration: none; + color: var(--fg-color); + transition: + background-color var(--transition), + padding-left var(--transition); + + &:hover { + background-color: var(--bg-muted-2); + padding-left: 1rem; + } + } + } +} diff --git a/modules/amezing/sass/components/_search-modal.scss b/modules/amezing/sass/components/_search-modal.scss new file mode 100644 index 0000000..2174293 --- /dev/null +++ b/modules/amezing/sass/components/_search-modal.scss @@ -0,0 +1,128 @@ +#search-modal { + display: flex; + position: fixed; + flex-direction: column; + align-items: center; + visibility: hidden; + opacity: 0; + z-index: 9999; + transition: + visibility var(--transition), + opacity var(--transition); + inset: 0; + background-color: var(--shade-color); + padding-block-start: 15vh; + width: 100%; + height: 100vh; + + &.active { + visibility: visible; + opacity: 1; + + #search-modal-content { + transform: none; + } + } + + &-content { + display: flex; + flex-direction: column; + gap: 1rem; + transform: var(--hover); + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + transition: + border-radius var(--transition), + padding var(--transition), + max-width var(--transition), + transform var(--transition-bezier-longer); + margin: -0.5rem; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: 2.25rem 2.25rem 1.75rem 1.75rem; + background-color: var(--glass-bg-1); + padding: 1rem; + width: calc(100% - 2rem); + max-width: calc(var(--content-width) / 1.5); + overflow: hidden; + + &:has(#search-results:empty) { + border-radius: 1.75rem; + padding: 0.5rem; + max-width: calc(var(--content-width) / 2); + } + + & > div { + position: relative; + } + } + + #search-input { + flex-shrink: 0; + box-shadow: var(--edge-highlight); + border-radius: 9999px; + padding: 0 1rem; + width: 100%; + height: 2.5rem; + + &:placeholder-shown + div .icon { + transform: none; + opacity: var(--disabled-opacity); + pointer-events: all; + } + + & + div .icon { + position: absolute; + transform: translateX(0.5rem); + opacity: 0; + transition: + opacity var(--transition), + transform var(--transition-bezier); + cursor: help; + inset-block-start: 0.625rem; + inset-inline-end: 1rem; + pointer-events: none; + color: var(--fg-muted-2); + font-size: 1.25rem; + } + } + + #search-results { + display: none; + flex: 1; + flex-direction: column; + gap: 0.5rem; + margin: -1rem; + padding: 1rem; + max-height: 50vh; + overflow: auto; + scrollbar-width: thin; + + .item { + display: inline-flex; + flex-direction: column; + box-shadow: var(--edge-highlight); + border-radius: var(--rounded-corner); + background-color: var(--bg-muted-1); + padding: 0.75rem 1rem; + + a { + text-decoration: none; + + &::after { + content: " →"; + } + } + + span { + margin-block-start: 0.25rem; + border-block-start: rpx(1) solid var(--bg-muted-2); + padding-block-start: 0.25rem; + color: var(--fg-muted-2); + + strong { + color: var(--fg-color); + } + } + } + } +} diff --git a/modules/amezing/sass/components/_skip-to-content.scss b/modules/amezing/sass/components/_skip-to-content.scss new file mode 100644 index 0000000..1e217af --- /dev/null +++ b/modules/amezing/sass/components/_skip-to-content.scss @@ -0,0 +1,23 @@ +#skip-to-content { + position: absolute; + transform: translateX(-50%) translateY(calc(-100% - 1rem)); + z-index: 9999; + transition: + backdrop-filter var(--transition), + box-shadow var(--transition), + transform var(--transition-bezier); + inset-inline-start: 50vw; + border-radius: 9999px; + background-color: var(--glass-bg-1); + padding: 0.375rem 0.75rem; + width: max-content; + text-decoration: none; + + &:focus-visible, + &:focus { + -webkit-backdrop-filter: var(--blur); + transform: translateX(-50%) translateY(1rem); + backdrop-filter: var(--blur); + box-shadow: var(--edge-highlight), var(--shadow); + } +} diff --git a/modules/amezing/sass/components/_statements.scss b/modules/amezing/sass/components/_statements.scss new file mode 100644 index 0000000..f76e587 --- /dev/null +++ b/modules/amezing/sass/components/_statements.scss @@ -0,0 +1,61 @@ +@use "../citrine/abstracts/functions" as *; + +.statement-container { + margin: 1rem 0; + box-shadow: var(--edge-highlight), var(--shadow); + border-radius: var(--rounded-corner); + padding: 1rem; + + :last-child { + margin-block-end: 0; + } + + & > :nth-child(2) { + margin-block-start: 0.5rem; + } + + .title { + font-weight: var(--font-weight-semi-bold); + + .icon { + vertical-align: rpx(-2); + margin-inline-end: 0.25rem; + } + } + + &.archive { + --accent-color: var(--purple-fg); + --accent-highlight: hsla( + var(--purple-h), + var(--purple-s), + var(--purple-l), + var(--disabled-opacity) + ); + background-color: var(--purple-bg-1); + color: var(--purple-fg); + } + + &.disclaimer { + --accent-color: var(--red-fg); + --accent-highlight: hsla( + var(--red-h), + var(--red-s), + var(--red-l), + var(--disabled-opacity) + ); + background-color: var(--red-bg-1); + color: var(--red-fg); + } + + &.trigger { + --accent-color: var(--yellow-fg); + --accent-highlight: hsla( + var(--yellow-h), + var(--yellow-s), + var(--yellow-l), + var(--disabled-opacity) + ); + background-color: var(--yellow-bg-1); + color: var(--yellow-fg); + } +} diff --git a/modules/amezing/sass/components/_tag-chips.scss b/modules/amezing/sass/components/_tag-chips.scss new file mode 100644 index 0000000..df38adc --- /dev/null +++ b/modules/amezing/sass/components/_tag-chips.scss @@ -0,0 +1,59 @@ +@use "../citrine/abstracts/functions" as *; + +#tag-chips { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin: 0; + padding-inline-start: 0; + list-style: none; + + li { + display: flex; + margin-block: 0; + padding-inline-start: 0; + } + + a { + display: flex; + transition: + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + box-shadow: var(--edge-highlight); + border-radius: 1rem; + background-color: var(--bg-muted-1); + padding: 0.25rem; + padding-inline-start: 0.75rem; + color: var(--fg-muted-1); + font-size: var(--font-size-small); + line-height: 1.5rem; + text-decoration: none; + + &:hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &::before { + content: "#"; + } + + .counter { + transition: inherit; + margin-inline-start: 0.375rem; + box-shadow: var(--edge-highlight); + border-radius: inherit; + background-color: inherit; + aspect-ratio: 1 / 1; + height: 1.5rem; + line-height: 1.5rem; + font-variant-numeric: tabular-nums; + text-align: center; + } + } +} diff --git a/modules/amezing/sass/components/_zola-anchor.scss b/modules/amezing/sass/components/_zola-anchor.scss new file mode 100644 index 0000000..f942600 --- /dev/null +++ b/modules/amezing/sass/components/_zola-anchor.scss @@ -0,0 +1,50 @@ +h1, +h2, +h3, +h4, +h5, +h6 { + &:hover .zola-anchor { + transform: none; + opacity: 1; + } + + &:has(.zola-anchor) { + position: relative; + } +} + +.zola-anchor { + position: absolute; + transform: translateX(0.25em); + opacity: 0; + transition: + color var(--transition), + opacity var(--transition), + transform var(--transition-bezier); + inset-inline-start: -1.25em; + padding-inline-end: 0.25em; + color: var(--fg-muted-1); + line-height: 1; + + @media only screen and (max-width: 720px) { + display: none; + } + + &:hover { + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &:focus-visible { + opacity: 1; + } +} + +:target .zola-anchor { + transform: none; + opacity: var(--disabled-opacity); +} diff --git a/modules/amezing/sass/components/_zola-error.scss b/modules/amezing/sass/components/_zola-error.scss new file mode 100644 index 0000000..433f6cc --- /dev/null +++ b/modules/amezing/sass/components/_zola-error.scss @@ -0,0 +1,55 @@ +// Based on https://github.com/welpo/tabi/blob/68c35b02f8c09e21ecff48f61bad6da3d96ce337/sass/parts/_zola-error.scss + +body > div:last-child:has(div:last-child[style]:not([class]):not([id])) { + z-index: 9999 !important; + background-color: var(--shade-color) !important; +} + +body > div:last-child > div:last-child[style]:not([class]):not([id]) { + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: calc(var(--rounded-corner) + 1rem) !important; + background-color: hsla( + var(--red-h), + calc(var(--red-s) - var(--red-s) * 0.7), + calc(var(--red-l) + (100% - var(--red-l)) * 0.9), + var(--dim-opacity) + ) !important; + padding: 1rem !important; + width: fit-content; + max-width: calc(100% - 2rem); + -webkit-backdrop-filter: var(--blur); + backdrop-filter: var(--blur); + filter: none !important; + + @media (prefers-color-scheme: dark) { + & { + background-color: hsla( + var(--red-h), + calc(var(--red-s) - var(--red-s) * 0.6), + calc(var(--red-l) - var(--red-l) * 0.8), + var(--dim-opacity) + ) !important; + } + } +} + +body + > div:last-child + > div:last-child[style]:not([class]):not([id]) + > p[style]:first-child { + margin-block-end: 1rem !important; + color: var(--red-fg) !important; + font-weight: var(--font-weight-semi-bold) !important; + font-size: var(--font-size-large) !important; +} + +body + > div:last-child + > div:last-child[style]:not([class]):not([id]) + > pre[style]:last-child { + border-radius: var(--rounded-corner) !important; + background-color: var(--red-bg-1) !important; + padding: 0.75rem 1rem !important; + color: var(--red-fg) !important; + font-weight: var(--font-weight-normal) !important; +} diff --git a/modules/amezing/sass/fonts.scss b/modules/amezing/sass/fonts.scss new file mode 100644 index 0000000..a148e87 --- /dev/null +++ b/modules/amezing/sass/fonts.scss @@ -0,0 +1,52 @@ +$commissioner-version: "1.012.1"; +$commit-mono-version: "1.143"; + +:root { + --font-sans-serif: "Commissioner"; + --font-monospace: "Commit Mono"; +} + +// Commissioner +@font-face { + font-style: normal; + font-weight: 100 900; + src: url("/fonts/commissioner.woff2?v=#{$commissioner-version}") + format("woff2"); + font-family: "Commissioner"; + font-display: swap; + // Fix vertical alignment, + // See https://github.com/kosbarts/Commissioner/issues/55 + ascent-override: 96.3%; + descent-override: 25%; +} + +@font-face { + font-style: italic; + font-weight: 100 900; + src: url("/fonts/commissioner.woff2?v=#{$commissioner-version}") + format("woff2"); + font-family: "Commissioner"; + font-display: swap; + font-variation-settings: "slnt" -12; + // Ditto + ascent-override: 96.3%; + descent-override: 25%; +} + +// Commit Mono +@font-face { + font-style: normal; + font-weight: 100 900; + src: url("/fonts/commit-mono.woff2?v=#{$commit-mono-version}") format("woff2"); + font-family: "Commit Mono"; + font-display: swap; +} + +@font-face { + font-style: italic; + font-weight: 100 900; + src: url("/fonts/commit-mono.woff2?v=#{$commit-mono-version}") format("woff2"); + font-family: "Commit Mono"; + font-display: swap; + font-variation-settings: "ital" 1; +} diff --git a/modules/amezing/sass/layout/_footer.scss b/modules/amezing/sass/layout/_footer.scss new file mode 100644 index 0000000..25a09fe --- /dev/null +++ b/modules/amezing/sass/layout/_footer.scss @@ -0,0 +1,140 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-footer { + --padding: 1rem; + display: flex; + grid-area: footer; + gap: 1rem; + margin-block: 3.25rem 1rem; + border-block-start: rpx(1) solid var(--bg-muted-2); + padding: 1rem; + width: 100%; + min-width: 0; + max-width: calc(var(--content-width) + var(--padding) * 2); + + @media only screen and (max-width: 480px) { + flex-direction: column-reverse; + } + + & > div { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + + p { + margin: 0; + } + + &:nth-of-type(1) { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + &:nth-of-type(2) { + justify-content: space-between; + align-items: flex-end; + text-align: end; + + @media only screen and (max-width: 480px) { + align-items: normal; + text-align: start; + } + } + } + + a.external::after { + margin-inline-start: 0.25rem; + } + + .link { + @include chip; + display: inline-block; + line-height: 1.5rem; + + &:hover { + background-color: var(--bg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + // IMO it's pretty cute + .webrings { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-block: 0; + padding-inline-start: 0; + list-style: none; + } + + .webring { + display: flex; + gap: 0.25rem; + margin-block: 0; + padding-inline-start: 0; + + .random, + .previous, + .next { + display: inline-flex; + justify-content: center; + align-items: center; + border-radius: 9999px; + padding: 0.25rem; + font-size: 1rem; + } + } + + .timestamp { + color: var(--fg-muted-1); + font-size: var(--font-size-xx-small); + font-family: var(--font-monospace); + text-transform: uppercase; + } + + .socials { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + margin: 0; + padding-inline-start: 0; + list-style: none; + + @media only screen and (max-width: 480px) { + justify-content: normal; + } + + li { + margin-block: 0; + padding-inline-start: 0; + } + + a { + display: flex; + transition: + color var(--transition), + transform var(--transition-bezier); + height: fit-content; + color: var(--fg-muted-1); + + &:hover { + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + svg { + fill: currentColor; + width: 1.5rem; + height: 1.5rem; + } + } +} diff --git a/modules/amezing/sass/layout/_full-bleed.scss b/modules/amezing/sass/layout/_full-bleed.scss new file mode 100644 index 0000000..85e9fd3 --- /dev/null +++ b/modules/amezing/sass/layout/_full-bleed.scss @@ -0,0 +1,13 @@ +.full-bleed { + transform: translateX(-50%); + margin-inline-start: 50%; + width: calc(100vw - var(--sidebar-width)); + + :root[dir*="rtl"] & { + transform: translateX(50%); + } + + @media only screen and (max-width: 960px) { + width: calc(100vw - 3.5rem); + } +} diff --git a/modules/amezing/sass/layout/_grid.scss b/modules/amezing/sass/layout/_grid.scss new file mode 100644 index 0000000..6a8b9db --- /dev/null +++ b/modules/amezing/sass/layout/_grid.scss @@ -0,0 +1,16 @@ +body { + display: grid; + grid-template-rows: auto auto 1fr auto; + grid-template-columns: var(--sidebar-width) 1fr; + grid-template-areas: + "sidebar header" + "sidebar banner" + "sidebar main " + "sidebar footer"; + justify-items: center; + transition: grid-template-columns var(--transition); + + @media only screen and (max-width: 960px) { + grid-template-columns: 0 1fr; + } +} diff --git a/modules/amezing/sass/layout/_header.scss b/modules/amezing/sass/layout/_header.scss new file mode 100644 index 0000000..0ecab63 --- /dev/null +++ b/modules/amezing/sass/layout/_header.scss @@ -0,0 +1,347 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-header { + display: flex; + position: sticky; + grid-area: header; + // height: 3rem; + justify-content: space-between; + // flex-wrap: nowrap; + align-items: center; + gap: 0.25rem; + z-index: 2; + // height: 3.75rem; + inset-block-start: 0; + // padding: 0.5rem 0.75rem; + padding: 0.5rem; + width: 100%; + min-width: 0; + + &::before, + &::after { + position: absolute; + inset: 0; + @include easing-mask(bottom); + inset-block-end: -100%; + pointer-events: none; + content: ""; + } + + &::before { + z-index: -2; + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + } + + &::after { + z-index: -1; + background-color: var(--bg-color); + } + + .title { + margin-inline: 0.75rem; + min-width: 0; + overflow: hidden; + color: var(--fg-muted-2); + font-size: var(--font-size-small); + line-height: 2rem; + text-overflow: ellipsis; + white-space: nowrap; + } + + .buttons { + display: flex; + flex-wrap: nowrap; + gap: 0.25rem; + margin: 0; + // margin-inline-start: auto; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: 1.125rem; + background-color: var(--glass-bg-1); + padding: 0.25rem; + list-style: none; + + li { + flex: 0; + margin-block: 0; + padding-inline-start: 0; + } + + & > li { + height: fit-content; + line-height: 0; + } + + button, + details { + box-shadow: none; + background-color: transparent; + line-height: inherit; + + .icon { + margin-inline-end: 0; + box-shadow: none; + background-color: transparent; + padding: 0; + } + } + + button, + summary { + border-radius: 50%; + padding: 0.375rem; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + details { + box-shadow: none; + border-radius: 0; + padding: 0; + } + + button { + font-size: var(--font-size-medium); + } + + summary { + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + } + + &::before { + display: none; + } + } + + details { + display: block; + position: relative; + margin-block-end: 0; + + &::details-content { + block-size: auto; + } + + &[open] { + &::details-content { + block-size: auto; + } + + summary { + color: var(--fg-muted-1); + + &:hover { + color: var(--fg-muted-2); + } + } + + @supports not (block-size: calc-size(auto, size)) { + summary ~ * { + animation: none; + } + } + } + } + } +} + +.buttons .dropdown { + display: flex; + position: absolute; + flex-direction: column; + gap: 0.25rem; + transform-origin: top right; + z-index: 1; + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + animation: expand-up-right var(--transition-bezier); + inset-block-start: calc(100% + 0.75rem); + inset-inline-end: -0.25rem; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: calc(var(--rounded-corner) + 0.25rem); + background-color: var(--glass-bg-1); + padding: 0.25rem; + width: max-content; + min-width: 8rem; + list-style: none; + + :root[dir*="rtl"] & { + transform-origin: top left; + animation: expand-up-left var(--transition-bezier); + } + + li { + flex: 1; + } + + a { + box-shadow: none; + border-radius: var(--rounded-corner); + background-color: transparent; + padding: 0.375rem 0.75rem; + width: 100%; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1) !important; + } + } +} + +#site-sidebar-controls { + display: flex; + flex-shrink: 0; + gap: 0.25rem; + transition: + width var(--transition), + visibility var(--transition); + margin-block: 0; + box-shadow: var(--edge-highlight), var(--shadow-glass); + border-radius: 1.125rem; + background-color: var(--glass-bg-1); + padding: 0.25rem; + height: fit-content; + line-height: 0; + list-style: none; + + @media only screen and (max-width: 960px) { + width: 2.25rem; + + &:has(#expand-sidebar:checked) { + width: 6.25rem; + + #site-sidebar-modes { + transform: none; + visibility: visible; + opacity: 1; + } + } + } + + ul { + padding-inline-start: 0; + list-style: none; + } + + li { + display: inline-block; + margin-block: 0; + padding-inline-start: 0; + + label { + display: inline-block; + transition: + box-shadow var(--transition), + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + cursor: pointer; + border-radius: 50%; + padding: 0.375rem; + height: fit-content; + color: var(--fg-muted-1); + line-height: 0; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + } + + input[type="radio"] { + & + label { + position: relative; + z-index: 1; + + &::before { + @include gradient-fill; + position: absolute; + opacity: 0; + z-index: -1; + transition: + opacity var(--transition), + box-shadow var(--transition), + background-color var(--transition); + inset: 0; + border-radius: inherit; + content: ""; + } + } + + &:checked + label { + color: var(--contrast-color); + + &:hover { + box-shadow: none; + background-color: transparent; + } + + &:hover::before { + box-shadow: var(--edge-highlight); + background-color: var(--accent-color); + } + + &::before { + opacity: 1; + } + } + } + + input[type="radio"], + input[type="checkbox"] { + &:focus-visible + label { + animation: focus-in var(--transition); + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.125rem; + } + + @supports not selector(:focus-visible) { + &:focus + label { + animation: focus-in var(--transition); + outline: 0.125rem solid var(--accent-color); + outline-offset: 0.125rem; + } + } + } + } + + #expand-sidebar-li { + display: none; + + @media only screen and (max-width: 960px) { + display: inline-block; + } + } +} + +#site-sidebar-modes { + display: flex; + gap: 0.25rem; + transform-origin: left; + transition: + visibility var(--transition), + opacity var(--transition), + transform var(--transition); + + @media only screen and (max-width: 960px) { + transform: translateX(-2.25rem); + visibility: hidden; + opacity: 0; + } +} diff --git a/modules/amezing/sass/layout/_index.scss b/modules/amezing/sass/layout/_index.scss new file mode 100644 index 0000000..b6da0b7 --- /dev/null +++ b/modules/amezing/sass/layout/_index.scss @@ -0,0 +1,6 @@ +@use "footer"; +@use "full-bleed"; +@use "grid"; +@use "header"; +@use "main-content"; +@use "sidebar"; \ No newline at end of file diff --git a/modules/amezing/sass/layout/_main-content.scss b/modules/amezing/sass/layout/_main-content.scss new file mode 100644 index 0000000..1c7c3b7 --- /dev/null +++ b/modules/amezing/sass/layout/_main-content.scss @@ -0,0 +1,59 @@ +#main-content { + --padding: 1rem; + grid-area: main; + transform-origin: 50% 50vh; + animation: fade-in-zoom var(--transition-bezier-longer); + margin-block-start: 3.25rem; + padding-inline: 1rem; + width: 100%; + min-width: 0; + max-width: calc(var(--content-width) + var(--padding) * 2); + + @supports (transform-origin: 50% 50dvh) { + transform-origin: 50% 50dvh; + } + + & > :first-child { + margin-block-start: 0; + } + + & > :last-child { + margin-block-end: 0; + } + + @keyframes fade-in-zoom { + from { + transform: scale(0.95); + opacity: 0; + } + } +} + +// Don't play animation when jumping to section +:target ~ #main-content { + animation-name: none; +} + +// Loading animation when going up in the sidebar +#↑:target ~ #main-content, +#up:target ~ #main-content { + animation-name: slide-in-up-longer; +} + +// Loading animation when going down in the sidebar +#↓:target ~ #main-content, +#down:target ~ #main-content { + animation-name: slide-in-down-longer; +} + +// Loading animation when going to the previous article +#←:target ~ #main-content, +#left:target ~ #main-content { + animation-name: slide-in-left-longer; +} + +// Loading animation when going to the next article +#→:target ~ #main-content, +#right:target ~ #main-content { + animation-name: slide-in-right-longer; +} diff --git a/modules/amezing/sass/layout/_sidebar.scss b/modules/amezing/sass/layout/_sidebar.scss new file mode 100644 index 0000000..6f7655e --- /dev/null +++ b/modules/amezing/sass/layout/_sidebar.scss @@ -0,0 +1,775 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-sidebar { + display: flex; + position: sticky; + grid-area: sidebar; + flex-direction: column; + z-index: 3; + transition-duration: var(--transition); + transition-property: + visibility, transform, inset-block-start, box-shadow, border-inline-end, + border-start-end-radius, border-end-end-radius, height; + inset-block-start: 0; + inset-inline-start: 0; + border-inline-end: rpx(1) solid var(--bg-muted-2); + width: var(--sidebar-width); + height: 100vh; + + @supports (height: 100dvh) { + height: 100dvh; + } + + @media only screen and (max-width: 960px) { + transform: translateX(-100%); + visibility: hidden; + inset-block-start: 3.25rem; + box-shadow: var(--edge-highlight), var(--shadow); + border-inline-end: 0 solid transparent; + border-start-end-radius: var(--rounded-corner); + border-end-end-radius: var(--rounded-corner); + height: calc(100vh - 3.75rem); + + :root[dir*="rtl"] & { + transform: translateX(100%); + } + + @supports (height: 100dvh) { + height: calc(100dvh - 3.75rem); + } + + #site-header:has(#expand-sidebar:checked) ~ & { + transform: none; + visibility: visible; + backdrop-filter: var(--blur); + -webkit-backdrop-filter: var(--blur); + background-color: var(--glass-bg-1); + } + } + + &-content { + display: flex; + position: relative; + flex: 1; + overflow: hidden; + } +} + +#site-sidebar-header { + display: flex; + align-items: center; + transform-origin: rpx(22) 50%; + transition: + box-shadow var(--transition), + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + margin: 1rem; + margin-block-end: 0; + border-radius: 1.375rem; + padding: 0.25rem; + padding-inline-end: 0.75rem; + min-width: 0; + color: var(--fg-muted-1); + text-decoration: none; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + + .icon { + background-color: var(--bg-muted-2); + } + } + + &:active { + transform: var(--active); + } + + img { + margin: 0; + } + + img, + .icon { + margin-inline-end: 0.5rem; + border-radius: 50%; + } + + img { + width: 2.25rem; + height: 2.25rem; + } + + .icon { + transition: background-color var(--transition); + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + padding: 0.5rem; + font-size: 1.25rem; + } + + div { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.125rem; + min-width: 0; + line-height: 1.25; + + span:nth-of-type(1) { + overflow: hidden; + font-size: var(--font-size-small); + text-overflow: ellipsis; + white-space: nowrap; + } + + span:nth-of-type(2) { + font-weight: var(--font-weight-normal); + font-size: var(--font-size-x-small); + } + } +} + +#sidebar-nav, +#sidebar-meta { + display: flex; + flex: 1; + flex-direction: column; + transition: + visibility var(--transition), + opacity var(--transition), + filter var(--transition), + transform var(--transition-bezier); + padding: 1rem; + width: var(--sidebar-width); + min-width: 100%; + overflow: hidden auto; + scrollbar-width: thin; +} + +#site-header:has(#site-sidebar-modes #mode-meta:checked) + #site-sidebar { + #sidebar-nav { + transform: perspective(var(--content-width)) rotateY(15deg); + opacity: var(--disabled-opacity); + filter: blur(0.25rem); + } + + #sidebar-meta { + transform: perspective(var(--content-width)); + visibility: visible; + opacity: 1; + filter: none; + } +} + +#sidebar-nav { + transform: perspective(var(--content-width)); + transform-origin: left; + + :root[dir*="rtl"] & { + transform-origin: right; + } + + a { + display: flex; + flex: 1; + align-items: center; + transform-origin: rpx(22) 50%; + transition: + box-shadow var(--transition), + background-color var(--transition), + color var(--transition), + transform var(--transition-bezier); + border-radius: 1.375rem; + padding: 0.25rem; + padding-inline-end: 0.75rem; + overflow: hidden; + color: var(--fg-muted-1); + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--fg-muted-2); + + .icon { + background-color: var(--bg-muted-2); + } + } + + &:active { + transform: var(--active); + } + + &[aria-current="page"] { + box-shadow: var(--edge-highlight); + background-color: var(--bg-muted-1); + color: var(--accent-color); + + .icon { + @include gradient-fill; + text-shadow: none; + } + + &:hover { + background-color: var(--bg-muted-2); + + .icon { + @include gradient-fill(hover); + } + } + } + + &.new .icon { + position: relative; + + &::after { + position: absolute; + inset-block-start: rpx(1); + inset-inline-end: rpx(1); + border-radius: 50%; + background-color: var(--accent-color); + width: 0.75rem; + height: 0.75rem; + content: ""; + } + } + } + + .icon { + transition: + background-color var(--transition), + color var(--transition), + box-shadow var(--transition); + margin-inline-end: 0.5rem; + box-shadow: var(--edge-highlight); + border-radius: 50%; + background-color: var(--bg-muted-1); + padding: 0.5rem; + font-size: 1.25rem; + + &.fallback { + width: 1.25rem; + font-weight: var(--font-weight-semi-bold); + font-size: var(--font-size-medium); + line-height: 1.25rem; + font-family: var(--font-monospace); + text-align: center; + + &::before { + content: none; + } + } + } + + details { + flex: 1; + margin-block-end: 0; + box-shadow: none; + border-radius: 0; + background-color: transparent; + padding: 0; + + &[open] { + summary { + transform: none; + opacity: 1; + box-shadow: none; + background-color: transparent; + + &::before { + transform: none; + } + + @supports not (block-size: calc-size(auto, size)) { + & ~ * { + animation: none; + } + } + } + } + + summary { + display: flex; + position: relative; + align-items: center; + border-radius: 0; + padding: 0.75rem 0; + color: var(--fg-muted-1); + font-weight: var(--font-weight-normal); + font-size: var(--font-size-small); + line-height: var(--font-size-small); + white-space: nowrap; + + &:hover { + box-shadow: none; + background-color: transparent; + } + + &:active { + transform: none; + } + + &::before { + position: absolute; + transform: rotate(-90deg); + transition: transform var(--transition-bezier); + margin-inline-end: 0; + inset-block: 0.5rem; + inset-inline-end: 0; + font-size: 1rem; + } + + .line { + flex: 1; + margin-inline: 0.5rem 1.5rem; + background-color: var(--bg-muted-2); + height: rpx(1); + } + } + + ul { + margin-block: 0; + } + } + + ul { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-block: auto; + padding-inline-start: 0; + list-style: none; + } + + li { + display: flex; + transition: width var(--transition); + margin-block: 0; + padding-inline-start: 0; + width: 100%; + } +} + +#sidebar-meta { + position: absolute; + transform: perspective(var(--content-width)) rotateY(-30deg); + transform-origin: left; + visibility: hidden; + opacity: 0; + filter: blur(0.25rem); + inset: 0; + + :root[dir*="rtl"] & { + transform-origin: right; + } + + &:has(> div:empty) { + .status-page { + display: flex; + } + + & > div { + display: none; + } + } + + & > div { + display: flex; + flex-direction: column; + gap: 1rem; + margin-block: auto; + } + + .card { + background-color: var(--glass-bg-1); + } + + .while-writing { + display: inherit; + position: relative; + flex-direction: inherit; + gap: inherit; + margin: calc(-0.25rem - rpx(1)); + margin-block-start: 1rem; + border: rpx(1) solid var(--bg-muted-2); + border-block-start: none; + border-end-start-radius: calc(var(--rounded-corner) + 0.25rem + #{rpx(1)}); + border-end-end-radius: calc(var(--rounded-corner) + 0.25rem + #{rpx(1)}); + padding: 0.25rem; + + .card-title { + display: flex; + position: absolute; + gap: 0.25rem; + inset-block-start: calc(var(--rounded-corner-small) * -1); + inset-inline: rpx(-1); + height: var(--rounded-corner-small); + + .line { + border: rpx(1) solid var(--bg-muted-2); + border-block-end: none; + } + + .line:nth-of-type(1) { + border-inline-end: none; + border-start-start-radius: var(--rounded-corner); + width: var(--rounded-corner); + } + + .line:nth-of-type(2) { + flex: 1; + border-inline-start: none; + border-start-end-radius: var(--rounded-corner); + } + + .title { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + line-height: 0; + } + } + } + + .weather { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + border-radius: 1.375rem; + padding: 0.25rem; + padding-inline-end: 1.25rem; + line-height: 1; + + &.cloudy { + animation: perlin 12s linear infinite; + background-image: url("/weather/perlin.png"); + + @media (prefers-color-scheme: dark) { + background-image: url("/weather/perlin-dark.png"); + } + } + + &.showers { + animation: rain 3s linear infinite; + background-image: url("/weather/rain.svg"); + + @media (prefers-color-scheme: dark) { + background-image: url("/weather/rain-dark.svg"); + } + } + + &.snowing { + animation: snow 12s linear infinite; + background-image: url("/weather/snow.svg"); + + @media (prefers-color-scheme: dark) { + background-image: url("/weather/snow-dark.svg"); + } + } + + div { + display: flex; + align-items: center; + gap: 0.5rem; + } + + .title { + color: inherit; + font-size: var(--font-size-medium); + } + + .icon { + box-shadow: var(--edge-highlight); + border-radius: 50%; + background-color: var(--bg-muted-1); + padding: 0.5rem; + font-size: 1.25rem; + } + + .condition { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + @keyframes rain { + to { + background-position: 256px 320px; + } + } + + @keyframes perlin { + to { + background-position: -256px -64px; + } + } + + @keyframes snow { + to { + background-position: 256px 512px; + } + } + } + + .music { + display: flex; + position: relative; + flex-direction: column; + gap: 0.5rem; + border-radius: 1rem; + + &::after { + position: absolute; + z-index: 1; + inset: 0; + box-shadow: var(--edge-highlight); + border-radius: inherit; + max-height: 5rem; + pointer-events: none; + content: ""; + } + + .carousel { + scroll-snap-type: x mandatory; + -webkit-overflow-scrolling: touch; + display: flex; + margin-block: 0; + box-shadow: var(--shadow); + border-radius: inherit; + background-color: var(--glass-bg-1); + padding-inline-start: 0; + overflow-x: auto; + scroll-behavior: smooth; + scrollbar-width: none; + list-style: none; + + li { + display: flex; + position: relative; + flex-shrink: 0; + gap: 1rem; + margin-block: 0; + outline: none; + padding: 0.5rem; + width: 100%; + overflow: hidden; + scroll-snap-align: start; + + &.has-cover { + animation: cover-alternate 60s ease-in-out infinite; + background-image: var(--bg-overlay), var(--blurnail); + background-position: center; + background-size: 2000%; + + @keyframes cover-alternate { + // X 100% + // Y ┌─────────────┐ + // │ 1,9 │ + // │ 8 2 │ + // │ 3,7 │ + // │ 6 4 │ + // │ 5 │ + // 100% └─────────────┘ + + from { + background-position: 50% 0%; + } + + 12.5% { + background-position: 100% 25%; + } + + 25% { + background-position: 50% 50%; + } + + 37.5% { + background-position: 100% 75%; + } + + 50% { + background-position: 50% 100%; + } + + 62.5% { + background-position: 0% 75%; + } + + 75% { + background-position: 50% 50%; + } + + 87.5% { + background-position: 0% 25%; + } + + to { + background-position: 50% 0%; + } + } + + .music-type { + color: var(--fg-muted-neutral-1); + } + + .music-artist { + color: var(--fg-muted-neutral-2); + } + } + + & > div { + display: flex; + flex: 1; + flex-direction: column; + justify-content: space-between; + gap: 0.25rem; + margin-inline-end: 0.5rem; + margin-block: 0.25rem; + min-width: 0; + min-width: 0; + line-height: 1; + } + + a { + position: absolute; + z-index: 1; + inset: 0; + border-radius: inherit; + } + } + } + + .music-type { + color: var(--fg-muted-1); + font-size: var(--font-size-xx-small); + } + + .music-artist { + color: var(--fg-muted-2); + font-size: var(--font-size-small); + } + + .title { + color: var(--fg-color); + font-size: var(--font-size-medium); + } + + img { + margin: 0; + border-radius: var(--rounded-corner-small); + } + + &-paginator { + display: flex; + justify-content: center; + gap: 0.25rem; + margin-inline: 1rem; + margin-block: 0; + padding-inline-start: 0; + list-style: none; + + li { + display: flex; + margin-block: 0; + padding-inline-start: 0; + height: 0.75rem; + } + + a { + transition: + width var(--transition), + height var(--transition), + background-color var(--transition), + transform var(--transition-bezier); + margin-block: auto; + border-radius: 0.375rem; + background-color: var(--fg-muted-1); + width: 1.5rem; + height: 0.375rem; + + &:hover { + background-color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &.active { + background-color: var(--fg-color); + width: 0.75rem; + height: 0.75rem; + } + } + } + } + + .toc { + display: flex; + flex-direction: column; + max-height: calc(var(--content-width) / 3); + + @media only screen and (max-width: 720px) { + max-height: calc(var(--content-width) / 2); + } + + .title { + margin-block-end: 1rem; + } + + .overshoot { + flex: 1; + margin: -1rem; + padding: 1rem; + overflow: auto; + scrollbar-width: thin; + + ul { + margin-block: 0; + padding-inline-start: 1rem; + color: var(--fg-muted-1); + font-size: var(--font-size-small); + } + + li { + margin-block: 0; + padding-inline-start: 0; + + &::marker { + color: inherit; + } + } + + a { + color: inherit; + text-decoration: none; + } + } + } + + .status-page { + display: none; + margin-block: auto; + text-align: center; + + .title, + p { + color: var(--fg-muted-2); + } + + .icon { + margin-inline: auto; + color: var(--fg-muted-1); + font-size: 3rem; + } + + p { + margin-block: 0; + } + } +} diff --git a/modules/amezing/sass/mods/_compact-sidebar.scss b/modules/amezing/sass/mods/_compact-sidebar.scss new file mode 100644 index 0000000..3169af3 --- /dev/null +++ b/modules/amezing/sass/mods/_compact-sidebar.scss @@ -0,0 +1,44 @@ +@use "../citrine/abstracts/functions" as *; +@use "../citrine/abstracts/mixins" as *; + +#site-sidebar-header { + transform-origin: rpx(18) 50%; + border-radius: var(--rounded-corner); + + img, + .icon { + border-radius: var(--rounded-corner-small); + } + + img { + width: 2rem; + height: 2rem; + } + + .icon { + padding: 0.375rem; + } + + div { + gap: 0; + line-height: 1.125; + } +} + +#sidebar-nav { + a { + transform-origin: rpx(18) 50%; + border-radius: var(--rounded-corner); + padding: 0.25rem; + + &.new .icon::after { + inset-block-start: rpx(-2); + inset-inline-end: rpx(-2); + } + } + + .icon { + border-radius: var(--rounded-corner-small); + padding: 0.375rem; + } +} diff --git a/modules/amezing/sass/mods/_flat.scss b/modules/amezing/sass/mods/_flat.scss new file mode 100644 index 0000000..92e04da --- /dev/null +++ b/modules/amezing/sass/mods/_flat.scss @@ -0,0 +1,27 @@ +@use "../citrine/abstracts/functions" as *; + +:root { + --color-opacity-1: 0.15; + --color-opacity-2: 0.25; + --edge-highlight: 0 0 0 transparent; + --shadow: 0 0 0 transparent; + --shadow-raised: 0 0 0 transparent; + --shadow-glass: 0 0 0 transparent; + --shadow-glow: 0 0 0 transparent; +} + +.buttons .suggested, +#site-nav a.active .icon { + box-shadow: none; + background-image: none; + background-color: var(--accent-color); + text-shadow: none; +} + +.buttons .suggested:hover { + opacity: var(--dim-opacity); +} + +.crt { + outline: rpx(1) solid var(--bg-muted-2); +} diff --git a/modules/amezing/sass/mods/_pill-tags.scss b/modules/amezing/sass/mods/_pill-tags.scss new file mode 100644 index 0000000..37985cf --- /dev/null +++ b/modules/amezing/sass/mods/_pill-tags.scss @@ -0,0 +1,27 @@ +@use "../citrine/abstracts/mixins" as *; + +.article-details .tags { + gap: 0.25rem; + margin-block-end: 0 !important; + + a { + @include chip; + display: flex; + color: var(--fg-muted-1); + font-weight: var(--font-weight-semi-bold); + line-height: 1.5rem; + + &:hover { + background-color: var(--bg-muted-2); + color: var(--fg-muted-2); + } + + &:active { + transform: var(--active); + } + + &::before { + content: none; + } + } +} diff --git a/modules/amezing/sass/mods/_skeuomorphic.scss b/modules/amezing/sass/mods/_skeuomorphic.scss new file mode 100644 index 0000000..7b382ea --- /dev/null +++ b/modules/amezing/sass/mods/_skeuomorphic.scss @@ -0,0 +1,30 @@ +// UNFINISHED, AND LIKELY WILL NEVER BE +@use "../citrine/abstracts/functions" as *; + +#site-nav { + a, + label, + button, + summary { + outline: rpx(1) solid var(--bg-muted-2); + box-shadow: var(--edge-highlight), var(--shadow); + background-image: linear-gradient( + to bottom, + var(--bg-muted-2), + var(--bg-muted-1) 50%, + transparent 50%, + var(--bg-muted-2) + ); + + .icon { + background-image: conic-gradient( + var(--bg-muted-2), + var(--bg-muted-1) 33%, + transparent, + var(--bg-muted-2) 66%, + transparent, + var(--bg-muted-2) + ); + } + } +} diff --git a/modules/amezing/sass/style.scss b/modules/amezing/sass/style.scss new file mode 100644 index 0000000..2288edd --- /dev/null +++ b/modules/amezing/sass/style.scss @@ -0,0 +1,40 @@ +/* +* Ametrine v0.1.0-alpha (https://ametrine.daudix.one) +* Copyright 2025 David "Daudix" Lapshin and contributors +* Licensed under MIT (https://codeberg.org/daudix/ametrine/raw/branch/main/LICENSE.txt) +*/ + +@use "layout/footer"; +@use "layout/full-bleed"; +@use "layout/grid"; +@use "layout/header"; +@use "layout/main-content"; +@use "layout/sidebar"; + +@use "components/alerts"; +@use "components/article-details"; +@use "components/article-list"; +@use "components/article-nav"; +@use "components/audio"; +@use "components/banner"; +@use "components/comments"; +@use "components/emoji"; +@use "components/footnotes"; +@use "components/giallo"; +@use "components/icon"; +@use "components/iframe"; +@use "components/marquee"; +@use "components/not-found"; +@use "components/pre-container"; +@use "components/search-modal"; +@use "components/skip-to-content"; +@use "components/statements"; +@use "components/tag-chips"; +@use "components/zola-anchor"; +@use "components/zola-error"; + +// Mods +// @use "mods/compact-sidebar"; +// @use "mods/flat"; +// @use "mods/pill-tags"; +// @use "mods/skeuomorphic"; // UNFINISHED, AND LIKELY WILL NEVER BE diff --git a/modules/amezing/scripts/audio-button.js b/modules/amezing/scripts/audio-button.js new file mode 100644 index 0000000..e76d3fc --- /dev/null +++ b/modules/amezing/scripts/audio-button.js @@ -0,0 +1,9 @@ +const audioButtons = document.querySelectorAll(".audio"); + +audioButtons.forEach((button) => { + button.addEventListener("click", (event) => playAudio(button.dataset.audio)); +}); + +function playAudio(url) { + new Audio(url).play(); +} diff --git a/modules/amezing/scripts/closable.js b/modules/amezing/scripts/closable.js new file mode 100644 index 0000000..a28774c --- /dev/null +++ b/modules/amezing/scripts/closable.js @@ -0,0 +1,34 @@ +const closable = document.querySelectorAll("details.closable"); + +closable.forEach((detail) => { + detail.addEventListener("toggle", () => { + if (detail.open) setTargetDetail(detail); + }); +}); + +function setTargetDetail(targetDetail) { + closable.forEach((detail) => { + if (detail !== targetDetail) { + detail.open = false; + } + }); +} + +document.addEventListener("click", function (event) { + const isClickInsideDetail = [...closable].some((detail) => + detail.contains(event.target), + ); + + if (!isClickInsideDetail) { + closable.forEach((detail) => { + detail.open = false; + }); + } +}); + +const expandSidebar = document.getElementById("expand-sidebar"); +const mainContent = document.getElementById("main-content"); + +mainContent.addEventListener("click", () => { + if (expandSidebar.checked) expandSidebar.checked = false; +}); diff --git a/modules/amezing/scripts/deanchor.js b/modules/amezing/scripts/deanchor.js new file mode 100644 index 0000000..94fbee5 --- /dev/null +++ b/modules/amezing/scripts/deanchor.js @@ -0,0 +1,10 @@ +const main = document.querySelector("#main-content"); + +main.addEventListener("animationend", () => { + const hash = decodeURIComponent(location.hash); + if ( + ["#↑", "#↓", "#←", "#→", "#up", "#down", "#left", "#right"].includes(hash) + ) { + history.replaceState(null, "", " "); + } +}); diff --git a/modules/amezing/scripts/fuse.js b/modules/amezing/scripts/fuse.js new file mode 100644 index 0000000..4357f5a --- /dev/null +++ b/modules/amezing/scripts/fuse.js @@ -0,0 +1,9 @@ +/** + * Fuse.js v7.2.0 - Lightweight fuzzy-search (http://fusejs.io) + * + * Copyright (c) 2026 Kiro Risk (http://kiro.me) + * All Rights Reserved. Apache Software License 2.0 + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +var e,t;e=this,t=function(){"use strict";function e(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter((function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable}))),n.push.apply(n,r)}return n}function t(t){for(var n=1;ne.length)&&(t=e.length);for(var n=0,r=new Array(t);n0&&void 0!==arguments[0]?arguments[0]:{},n=t.getFn,i=void 0===n?j.getFn:n,u=t.fieldNormWeight,o=void 0===u?j.fieldNormWeight:u;r(this,e),this.norm=function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:1,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:3,n=new Map,r=Math.pow(10,t);return{get:function(t){var i=t.match(L).length;if(n.has(i))return n.get(i);var u=1/Math.pow(i,.5*e),o=parseFloat(Math.round(u*r)/r);return n.set(i,o),o},clear:function(){n.clear()}}}(o,3),this.getFn=i,this.isCreated=!1,this.setIndexRecords()}return u(e,[{key:"setSources",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.docs=e}},{key:"setIndexRecords",value:function(){var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.records=e}},{key:"setKeys",value:function(){var e=this,t=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[];this.keys=t,this._keysMap={},t.forEach((function(t,n){e._keysMap[t.id]=n}))}},{key:"create",value:function(){var e=this;!this.isCreated&&this.docs.length&&(this.isCreated=!0,A(this.docs[0])?this.docs.forEach((function(t,n){e._addString(t,n)})):this.docs.forEach((function(t,n){e._addObject(t,n)})),this.norm.clear())}},{key:"add",value:function(e){var t=this.size();A(e)?this._addString(e,t):this._addObject(e,t)}},{key:"removeAt",value:function(e){this.records.splice(e,1);for(var t=e,n=this.size();t=0;t-=1)this.records.splice(e[t],1);for(var n=0,r=this.records.length;n2&&void 0!==arguments[2]?arguments[2]:{},r=n.getFn,i=void 0===r?j.getFn:r,u=n.fieldNormWeight,o=void 0===u?j.fieldNormWeight:u,c=new I({getFn:i,fieldNormWeight:o});return c.setKeys(e.map(_)),c.setSources(t),c.create(),c}var $=32;function R(e,t,n){var r=arguments.length>3&&void 0!==arguments[3]?arguments[3]:{},i=r.location,u=void 0===i?j.location:i,o=r.distance,c=void 0===o?j.distance:o,s=r.threshold,a=void 0===s?j.threshold:s,h=r.findAllMatches,l=void 0===h?j.findAllMatches:h,f=r.minMatchCharLength,d=void 0===f?j.minMatchCharLength:f,v=r.includeMatches,g=void 0===v?j.includeMatches:v,y=r.ignoreLocation,p=void 0===y?j.ignoreLocation:y;if(t.length>$)throw new Error("Pattern length exceeds max of ".concat($,"."));for(var A,m=t.length,C=e.length,k=Math.max(0,Math.min(u,C)),F=a,b=k,M=function(e,t){var n=e/m;if(p)return n;var r=Math.abs(k-t);return c?n+r/c:r?1:n},E=d>1||g,D=E?Array(C):[];(A=e.indexOf(t,b))>-1;){var B=M(0,A);if(F=Math.min(B,F),b=A+m,E)for(var x=0;x=R;z-=1){var T=z-1,K=n[e[T]];if(E&&(D[T]=+!!K),W[z]=(W[z+1]<<1|1)&K,L&&(W[z]|=(_[z+1]|_[z])<<1|1|_[z+1]),W[z]&O&&(S=M(L,T))<=F){if(F=S,(b=T)<=k)break;R=Math.max(1,2*k-b)}}if(M(L+1,k)>F)break;_=W}var U={isMatch:b>=0,score:Math.max(.001,S)};if(E){var q=function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:[],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:j.minMatchCharLength,n=[],r=-1,i=-1,u=0,o=e.length;u=t&&n.push([r,i]),r=-1)}return e[u-1]&&u-r>=t&&n.push([r,u-1]),n}(D,d);q.length?g&&(U.indices=q):U.isMatch=!1}return U}function P(e){for(var t={},n=0,r=e.length;n1&&void 0!==arguments[1]?arguments[1]:{},u=i.location,o=void 0===u?j.location:u,c=i.threshold,s=void 0===c?j.threshold:c,a=i.distance,h=void 0===a?j.distance:a,l=i.includeMatches,f=void 0===l?j.includeMatches:l,d=i.findAllMatches,v=void 0===d?j.findAllMatches:d,g=i.minMatchCharLength,y=void 0===g?j.minMatchCharLength:g,p=i.isCaseSensitive,A=void 0===p?j.isCaseSensitive:p,m=i.ignoreDiacritics,C=void 0===m?j.ignoreDiacritics:m,k=i.ignoreLocation,F=void 0===k?j.ignoreLocation:k;if(r(this,e),this.options={location:o,threshold:s,distance:h,includeMatches:f,findAllMatches:v,minMatchCharLength:y,isCaseSensitive:A,ignoreDiacritics:C,ignoreLocation:F},t=A?t:t.toLowerCase(),t=C?W(t):t,this.pattern=t,this.chunks=[],this.pattern.length){var b=function(e,t){n.chunks.push({pattern:e,alphabet:P(e),startIndex:t})},M=this.pattern.length;if(M>$){for(var E=0,D=M%$,B=M-D;E1&&void 0!==arguments[1]?arguments[1]:{},o=u.location,c=void 0===o?j.location:o,s=u.threshold,a=void 0===s?j.threshold:s,h=u.distance,l=void 0===h?j.distance:h,f=u.includeMatches,d=void 0===f?j.includeMatches:f,v=u.findAllMatches,g=void 0===v?j.findAllMatches:v,y=u.minMatchCharLength,p=void 0===y?j.minMatchCharLength:y,A=u.isCaseSensitive,m=void 0===A?j.isCaseSensitive:A,C=u.ignoreDiacritics,k=void 0===C?j.ignoreDiacritics:C,F=u.ignoreLocation,b=void 0===F?j.ignoreLocation:F;return r(this,n),(i=t.call(this,e))._bitapSearch=new z(e,{location:c,threshold:a,distance:l,includeMatches:d,findAllMatches:g,minMatchCharLength:p,isCaseSensitive:m,ignoreDiacritics:k,ignoreLocation:b}),i}return u(n,[{key:"search",value:function(e){return this._bitapSearch.searchIn(e)}}],[{key:"type",get:function(){return"fuzzy"}},{key:"multiRegex",get:function(){return/^"(.*)"$/}},{key:"singleRegex",get:function(){return/^(.*)$/}}]),n}(T),X=function(e){c(n,e);var t=l(n);function n(e){return r(this,n),t.call(this,e)}return u(n,[{key:"search",value:function(e){for(var t,n=0,r=[],i=this.pattern.length;(t=e.indexOf(this.pattern,n))>-1;)n=t+i,r.push([t,n-1]);var u=!!r.length;return{isMatch:u,score:u?0:1,indices:r}}}],[{key:"type",get:function(){return"include"}},{key:"multiRegex",get:function(){return/^'"(.*)"$/}},{key:"singleRegex",get:function(){return/^'(.*)$/}}]),n}(T),Y=[U,X,J,Q,G,V,q,H],Z=Y.length,ee=/ +(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)/,te=new Set([H.type,X.type]),ne=function(){function e(t){var n=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},i=n.isCaseSensitive,u=void 0===i?j.isCaseSensitive:i,o=n.ignoreDiacritics,c=void 0===o?j.ignoreDiacritics:o,s=n.includeMatches,a=void 0===s?j.includeMatches:s,h=n.minMatchCharLength,l=void 0===h?j.minMatchCharLength:h,f=n.ignoreLocation,d=void 0===f?j.ignoreLocation:f,v=n.findAllMatches,g=void 0===v?j.findAllMatches:v,y=n.location,p=void 0===y?j.location:y,A=n.threshold,m=void 0===A?j.threshold:A,C=n.distance,k=void 0===C?j.distance:C;r(this,e),this.query=null,this.options={isCaseSensitive:u,ignoreDiacritics:c,includeMatches:a,minMatchCharLength:l,findAllMatches:g,ignoreLocation:d,location:p,threshold:m,distance:k},t=u?t:t.toLowerCase(),t=c?W(t):t,this.pattern=t,this.query=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return e.split("|").map((function(e){for(var n=e.trim().split(ee).filter((function(e){return e&&!!e.trim()})),r=[],i=0,u=n.length;i2&&void 0!==arguments[2]?arguments[2]:{}).auto,r=void 0===n||n;return he(e)||(e=le(e)),function e(n){var i=Object.keys(n),u=function(e){return!!e[se]}(n);if(!u&&i.length>1&&!he(n))return e(le(n));if(function(e){return!g(e)&&k(e)&&!he(e)}(n)){var o=u?n[se]:i[0],c=u?n[ae]:n[o];if(!A(c))throw new Error(function(e){return"Invalid value for key ".concat(e)}(o));var s={keyId:w(o),pattern:c};return r&&(s.searcher=ue(c,t)),s}var a={children:[],operator:i[0]};return i.forEach((function(t){var r=n[t];g(r)&&r.forEach((function(t){a.children.push(e(t))}))})),a}(e)}function de(e,t){var n=t.ignoreFieldNorm,r=void 0===n?j.ignoreFieldNorm:n,i=1;e.matches.forEach((function(e){var t=e.key,n=e.norm,u=e.score,o=t?t.weight:null;i*=Math.pow(0===u&&o?Number.EPSILON:u,(o||1)*(r?1:n))})),e.score=i}var ve=function(){function e(t){r(this,e),this.limit=t,this.heap=[]}return u(e,[{key:"size",get:function(){return this.heap.length}},{key:"shouldInsert",value:function(e){return this.size0;){var n=e-1>>1;if(t[e].score<=t[n].score)break;var r=t[e];t[e]=t[n],t[n]=r,e=n}}},{key:"_sinkDown",value:function(e){var t=this.heap,n=t.length,r=e;do{var i=2*(e=r)+1,u=2*e+2;if(it[r].score&&(r=i),ut[r].score&&(r=u),r!==e){var o=t[e];t[e]=t[r],t[r]=o}}while(r!==e)}}]),e}();function ge(e,t){var n=e.matches;t.matches=[],F(n)&&n.forEach((function(e){if(F(e.indices)&&e.indices.length){var n={indices:e.indices,value:e.value};e.key&&(n.key=e.key.src),e.idx>-1&&(n.refIndex=e.idx),t.matches.push(n)}}))}function ye(e,t){t.score=e.score}var pe=function(){function e(n){var i=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},u=arguments.length>2?arguments[2]:void 0;r(this,e),this.options=t(t({},j),i),this.options.useExtendedSearch,this._keyStore=new x(this.options.keys),this.setCollection(n,u),this._lastQuery=null,this._lastSearcher=null}return u(e,[{key:"_getSearcher",value:function(e){if(this._lastQuery===e)return this._lastSearcher;var t=ue(e,this.options);return this._lastQuery=e,this._lastSearcher=t,t}},{key:"setCollection",value:function(e,t){if(this._docs=e,t&&!(t instanceof I))throw new Error("Incorrect 'index' type");this._myIndex=t||N(this.options.keys,this._docs,{getFn:this.options.getFn,fieldNormWeight:this.options.fieldNormWeight})}},{key:"add",value:function(e){F(e)&&(this._docs.push(e),this._myIndex.add(e))}},{key:"remove",value:function(){for(var e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:function(){return!1},t=[],n=[],r=0,i=this._docs.length;r=0;u-=1)this._docs.splice(n[u],1);this._myIndex.removeAll(n)}return t}},{key:"removeAt",value:function(e){this._docs.splice(e,1),this._myIndex.removeAt(e)}},{key:"getIndex",value:function(){return this._myIndex}},{key:"search",value:function(e){var t,n=(arguments.length>1&&void 0!==arguments[1]?arguments[1]:{}).limit,r=void 0===n?-1:n,i=this.options,u=i.includeMatches,o=i.includeScore,c=i.shouldSort,s=i.sortFn,a=i.ignoreFieldNorm;if(m(r)&&r>0&&A(e)){var h=new ve(r);A(this._docs[0])?this._searchStringList(e,{heap:h,ignoreFieldNorm:a}):this._searchObjectList(e,{heap:h,ignoreFieldNorm:a}),t=h.extractSorted(s)}else(function(e,t){var n=t.ignoreFieldNorm,r=void 0===n?j.ignoreFieldNorm:n;e.forEach((function(e){de(e,{ignoreFieldNorm:r})}))})(t=A(e)?A(this._docs[0])?this._searchStringList(e):this._searchObjectList(e):this._searchLogical(e),{ignoreFieldNorm:a}),c&&t.sort(s),m(r)&&r>-1&&(t=t.slice(0,r));return function(e,t){var n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{},r=n.includeMatches,i=void 0===r?j.includeMatches:r,u=n.includeScore,o=void 0===u?j.includeScore:u,c=[];return i&&c.push(ge),o&&c.push(ye),e.map((function(e){var n=e.idx,r={item:t[n],refIndex:n};return c.length&&c.forEach((function(t){t(e,r)})),r}))}(t,this._docs,{includeMatches:u,includeScore:o})}},{key:"_searchStringList",value:function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.heap,r=t.ignoreFieldNorm,i=this._getSearcher(e),u=this._myIndex.records,o=n?null:[];return u.forEach((function(e){var t=e.v,u=e.i,c=e.n;if(F(t)){var s=i.searchIn(t),a=s.isMatch,h=s.score,l=s.indices;if(a){var f={item:t,idx:u,matches:[{score:h,value:t,norm:c,indices:l}]};n?(de(f,{ignoreFieldNorm:r}),n.shouldInsert(f.score)&&n.insert(f)):o.push(f)}}})),o}},{key:"_searchLogical",value:function(e){var t=this,n=fe(e,this.options),r=function e(n,r,i){if(!n.children){var u=n.keyId,o=n.searcher,c=t._findMatches({key:t._keyStore.get(u),value:t._myIndex.getValueForItemAtKeyId(r,u),searcher:o});return c&&c.length?[{idx:i,item:r,matches:c}]:[]}for(var s=[],a=0,h=n.children.length;a1&&void 0!==arguments[1]?arguments[1]:{},r=n.heap,i=n.ignoreFieldNorm,u=this._getSearcher(e),o=this._myIndex,c=o.keys,s=o.records,a=r?null:[];return s.forEach((function(e){var n=e.$,o=e.i;if(F(n)){var s=[];if(c.forEach((function(e,r){s.push.apply(s,f(t._findMatches({key:e,value:n[r],searcher:u})))})),s.length){var h={idx:o,item:n,matches:s};r?(de(h,{ignoreFieldNorm:i}),r.shouldInsert(h.score)&&r.insert(h)):a.push(h)}}})),a}},{key:"_findMatches",value:function(e){var t=e.key,n=e.value,r=e.searcher;if(!F(n))return[];var i=[];if(g(n))n.forEach((function(e){var n=e.v,u=e.i,o=e.n;if(F(n)){var c=r.searchIn(n),s=c.isMatch,a=c.score,h=c.indices;s&&i.push({score:a,key:t,value:n,idx:u,norm:o,indices:h})}}));else{var u=n.v,o=n.n,c=r.searchIn(u),s=c.isMatch,a=c.score,h=c.indices;s&&i.push({score:a,key:t,value:u,norm:o,indices:h})}return i}}]),e}();return pe.version="7.2.0",pe.createIndex=N,pe.parseIndex=function(e){var t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{},n=t.getFn,r=void 0===n?j.getFn:n,i=t.fieldNormWeight,u=void 0===i?j.fieldNormWeight:i,o=e.keys,c=e.records,s=new I({getFn:r,fieldNormWeight:u});return s.setKeys(o),s.setIndexRecords(c),s},pe.config=j,ie(ne),pe.use=function(){for(var e=arguments.length,t=new Array(e),n=0;n { + const text = container.firstElementChild; + if (!text) return; + + const style = getComputedStyle(container); + const paddingLeft = parseFloat(style.paddingLeft); + const paddingRight = parseFloat(style.paddingRight); + + const containerInnerWidth = + container.clientWidth - paddingLeft - paddingRight; + + const textWidth = text.offsetWidth; + + text.style.setProperty("--text-width", `${textWidth}`); + text.style.setProperty("--container-width", `${containerInnerWidth}`); + + container.classList.toggle( + "overshoot-row", + textWidth > containerInnerWidth, + ); + }); +} + +window.addEventListener("load", updateMarquees); +window.addEventListener("resize", updateMarquees); diff --git a/modules/amezing/scripts/paginator.js b/modules/amezing/scripts/paginator.js new file mode 100644 index 0000000..334f85e --- /dev/null +++ b/modules/amezing/scripts/paginator.js @@ -0,0 +1,23 @@ +const carousel = document.querySelector(".music .carousel"); +const items = document.querySelectorAll(".music .carousel > li"); +const links = document.querySelectorAll(".music-paginator li a"); + +const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + links.forEach((a) => a.classList.remove("active")); + const active = document.querySelector( + `.music-paginator a[href="#${entry.target.id}"]`, + ); + active?.classList.add("active"); + } + }); + }, + { + root: carousel, + threshold: 0.6, + }, +); + +items.forEach((item) => observer.observe(item)); diff --git a/modules/amezing/scripts/search-fuse.js b/modules/amezing/scripts/search-fuse.js new file mode 100644 index 0000000..df56e7d --- /dev/null +++ b/modules/amezing/scripts/search-fuse.js @@ -0,0 +1,178 @@ +// Based on https://codeberg.org/daudix/duckquill/issues/101#issuecomment-2377169 +let searchSetup = false; +let fuse; + +async function initIndex() { + if (searchSetup) return; + + let data; + if (typeof window.searchIndex !== "undefined") { + data = window.searchIndex; + } else { + const response = await fetch( + `/search_index.${document.documentElement.lang}.json`, + ); + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); + data = await response.json(); + } + + const options = { + includeScore: false, + includeMatches: true, + ignoreLocation: true, + threshold: 0.15, + keys: [ + { name: "title", weight: 3 }, + { name: "description", weight: 2 }, + { name: "body", weight: 1 }, + ], + }; + + fuse = new Fuse(data, options); + searchSetup = true; + + console.log("Search index initialized successfully"); +} + +function debounce(actual_fn, wait) { + let timeoutId; + + return (...args) => { + clearTimeout(timeoutId); + + timeoutId = setTimeout(() => { + actual_fn(...args); + }, wait); + }; +} + +function initSearch() { + const searchModal = document.getElementById("search-modal"); // Full-screen modal + const searchModalContent = document.getElementById("search-modal-content"); // Actual modal box + const searchInput = document.getElementById("search-input"); // Search input + const searchResults = document.getElementById("search-results"); // Search results + const searchButton = document.getElementById("search"); // Search button + const MAX_ITEMS = 10; + const MAX_RESULTS = 4; + + let currentTerm = ""; + + // Open search modal when clicking the search button + if (searchButton) { + searchButton.addEventListener("click", function () { + searchModal.classList.add("active"); + initIndex(); + searchModal.addEventListener( + "transitionend", + function handler() { + searchInput.focus(); + searchModal.removeEventListener("transitionend", handler); + }, + { once: true }, + ); + }); + } + + // Open search modal on "/" key press + window.addEventListener("keydown", (event) => { + if ( + event.key === "/" && + document.activeElement.tagName !== "INPUT" && + document.activeElement.tagName !== "TEXTAREA" + ) { + event.preventDefault(); + initIndex(); + searchModal.classList.add("active"); + searchModal.addEventListener( + "transitionend", + function handler() { + searchInput.focus(); + searchModal.removeEventListener("transitionend", handler); + }, + { once: true }, + ); + } + }); + + // Close search modal on Escape key + window.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + searchModal.classList.remove("active"); + } + }); + + // Close search modal when clicking outside search-modal-content + searchModal.addEventListener("click", function (e) { + if (!searchModalContent.contains(e.target)) { + searchModal.classList.remove("active"); + } + }); + + // Prevent clicks inside modal content from closing it + searchModalContent.addEventListener("click", function (e) { + e.stopPropagation(); // Stops event from reaching searchModal click handler + }); + + // Search input event + searchInput.addEventListener( + "keyup", + debounce(async function () { + var term = searchInput.value.trim(); + if (term === currentTerm) return; + + searchResults.style.display = term === "" ? "none" : "flex"; + searchResults.innerHTML = ""; // Clear previous results + currentTerm = term; + if (term === "") return; + + await initIndex(); + var results = fuse.search(term, { limit: MAX_ITEMS }); + if (results.length === 0) { + searchResults.style.display = "none"; + return; + } + + // Insert formatted search result items + for (const result of results) { + searchResults.innerHTML += makeTeaser(result, term); + } + }, 150), + ); + + function makeTeaser(result, searchVal) { + const TEASER_SIZE = 20; + let output = `"; + } +} + +if ( + document.readyState === "complete" || + (document.readyState !== "loading" && !document.documentElement.doScroll) +) { + initSearch(); +} else { + document.addEventListener("DOMContentLoaded", initSearch); +} diff --git a/modules/amezing/styles/ametrine.css b/modules/amezing/styles/ametrine.css new file mode 100644 index 0000000..2a20007 --- /dev/null +++ b/modules/amezing/styles/ametrine.css @@ -0,0 +1 @@ +:root{--font-sans-serif: "Commissioner";--font-monospace: "Commit Mono"}@font-face{font-style:normal;font-weight:100 900;src:url("/fonts/commissioner.woff2?v=1.012.1") format("woff2");font-family:"Commissioner";font-display:swap;ascent-override:96.3%;descent-override:25%}@font-face{font-style:italic;font-weight:100 900;src:url("/fonts/commissioner.woff2?v=1.012.1") format("woff2");font-family:"Commissioner";font-display:swap;font-variation-settings:"slnt" -12;ascent-override:96.3%;descent-override:25%}@font-face{font-style:normal;font-weight:100 900;src:url("/fonts/commit-mono.woff2?v=1.143") format("woff2");font-family:"Commit Mono";font-display:swap}@font-face{font-style:italic;font-weight:100 900;src:url("/fonts/commit-mono.woff2?v=1.143") format("woff2");font-family:"Commit Mono";font-display:swap;font-variation-settings:"ital" 1}:root{--bg-overlay: linear-gradient( rgba(255, 255, 255, var(--dim-opacity)), rgba(255, 255, 255, var(--dim-opacity)) );--fg-color: #29242a;--fg-contrast: #fcfcfa;--red-fg: hsl(342.0779, 72%, 58%);--red-h: 342.0779;--red-s: 72%;--red-l: 58%;--orange-fg: hsl(15.771428, 74%, 54%);--orange-h: 15.771428;--orange-s: 74%;--orange-l: 54%;--yellow-fg: hsl(34.639175, 91%, 42%);--yellow-h: 34.639175;--yellow-s: 91%;--yellow-l: 42%;--green-fg: hsl(153.7815, 61%, 38%);--green-h: 153.7815;--green-s: 61%;--green-l: 38%;--blue-fg: hsl(192, 71%, 38%);--blue-h: 192;--blue-s: 71%;--blue-l: 38%;--purple-fg: hsl(254.11766, 44%, 55%);--purple-h: 254.11766;--purple-s: 44%;--purple-l: 55%;--red-bg-1: hsla( var(--red-h), var(--red-s), var(--red-l), var(--color-opacity-1) );--red-bg-2: hsla( var(--red-h), var(--red-s), var(--red-l), var(--color-opacity-2) );--orange-bg-1: hsla( var(--orange-h), var(--orange-s), var(--orange-l), var(--color-opacity-1) );--orange-bg-2: hsla( var(--orange-h), var(--orange-s), var(--orange-l), var(--color-opacity-2) );--yellow-bg-1: hsla( var(--yellow-h), var(--yellow-s), var(--yellow-l), var(--color-opacity-1) );--yellow-bg-2: hsla( var(--yellow-h), var(--yellow-s), var(--yellow-l), var(--color-opacity-2) );--green-bg-1: hsla( var(--green-h), var(--green-s), var(--green-l), var(--color-opacity-1) );--green-bg-2: hsla( var(--green-h), var(--green-s), var(--green-l), var(--color-opacity-2) );--blue-bg-1: hsla( var(--blue-h), var(--blue-s), var(--blue-l), var(--color-opacity-1) );--blue-bg-2: hsla( var(--blue-h), var(--blue-s), var(--blue-l), var(--color-opacity-2) );--purple-bg-1: hsla( var(--purple-h), var(--purple-s), var(--purple-l), var(--color-opacity-1) );--purple-bg-2: hsla( var(--purple-h), var(--purple-s), var(--purple-l), var(--color-opacity-2) );--red-fg-muted-1: hsla( var(--red-h), var(--red-s), calc(var(--red-l) - var(--red-l) * 0.85), var(--fg-muted-1-opacity) );--red-fg-muted-2: hsla( var(--red-h), var(--red-s), calc(var(--red-l) - var(--red-l) * 0.85), var(--fg-muted-2-opacity) );--orange-fg-muted-1: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) - var(--orange-l) * 0.85), var(--fg-muted-1-opacity) );--orange-fg-muted-2: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) - var(--orange-l) * 0.85), var(--fg-muted-2-opacity) );--yellow-fg-muted-1: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) - var(--yellow-l) * 0.85), var(--fg-muted-1-opacity) );--yellow-fg-muted-2: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) - var(--yellow-l) * 0.85), var(--fg-muted-2-opacity) );--green-fg-muted-1: hsla( var(--green-h), var(--green-s), calc(var(--green-l) - var(--green-l) * 0.85), var(--fg-muted-1-opacity) );--green-fg-muted-2: hsla( var(--green-h), var(--green-s), calc(var(--green-l) - var(--green-l) * 0.85), var(--fg-muted-2-opacity) );--blue-fg-muted-1: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) - var(--blue-l) * 0.85), var(--fg-muted-1-opacity) );--blue-fg-muted-2: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) - var(--blue-l) * 0.85), var(--fg-muted-2-opacity) );--purple-fg-muted-1: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) - var(--purple-l) * 0.85), var(--fg-muted-1-opacity) );--purple-fg-muted-2: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) - var(--purple-l) * 0.85), var(--fg-muted-2-opacity) );--shade-color: rgba(0, 0, 0, 0.07);--bg-muted-neutral-1: rgba(0, 0, 0, var(--color-opacity-1));--bg-muted-neutral-2: rgba(0, 0, 0, var(--color-opacity-2));--fg-muted-neutral-1: rgba(0, 0, 0, var(--fg-muted-1-opacity));--fg-muted-neutral-2: rgba(0, 0, 0, var(--fg-muted-2-opacity));--scanlines-opacity: 0.4;--fg-muted-1-opacity: 0.5;--fg-muted-2-opacity: 0.6;--edge-highlight: inset 0 -0.0625rem 0 rgba(0, 0, 0, var(--color-opacity-1));--content-width: 45rem;--sidebar-width: clamp( calc(var(--content-width) / 3), 20vw, calc(var(--content-width) / 2) );--rounded-corner: 0.75rem;--rounded-corner-small: 0.5rem;--blur: blur(0.75rem) saturate(180%);--printed: contrast(2) brightness(0.75) grayscale(1);--drop-shadow: drop-shadow(0 0.08rem 0.1rem rgba(0, 0, 0, 0.35)) drop-shadow(0 0.4rem 0.7rem rgba(0, 0, 0, 0.18));--font-system-ui: system-ui, sans-serif;--font-ui-monospace: ui-monospace, monospace;--font-emoji: "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-size-xx-small: 0.625rem;--font-size-x-small: 0.75rem;--font-size-small: 0.875rem;--font-size-medium: 1rem;--font-size-large: 1.25rem;--font-size-x-large: 1.5rem;--font-size-xx-large: 2rem;--font-size-xxx-large: 3rem;--font-weight-thin: 100;--font-weight-extra-light: 200;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semi-bold: 600;--font-weight-bold: 700;--font-weight-extra-bold: 800;--font-weight-black: 900;--color-opacity-1: 0.1;--color-opacity-2: 0.2;--dim-opacity: 0.8;--disabled-opacity: 0.4;--shadow: 0 0 0 0.0625rem rgba(0, 0, 0, 0.03), 0 0.0625rem 0.1875rem 0.0625rem rgba(0, 0, 0, 0.07), 0 0.125rem 0.375rem 0.125rem rgba(0, 0, 0, 0.03);--shadow-inset: inset 0 0 0 0.0625rem rgba(0, 0, 0, 0.03), inset 0 0.0625rem 0.1875rem 0.0625rem rgba(0, 0, 0, 0.07), inset 0 0.125rem 0.375rem 0.125rem rgba(0, 0, 0, 0.03);--shadow-raised: 0 0 0 0.0625rem rgba(0, 0, 0, 0.06), 0 0.125rem 0.375rem 0.125rem rgba(0, 0, 0, 0.14), 0 0.25rem 0.75rem 0.25rem rgba(0, 0, 0, 0.06);--shadow-glass: 0 0.375rem 0.75rem -0.375rem rgba(0, 0, 0, 0.1);--hover: scale(1.1);--active: scale(0.9);--transition: 0.2s;--transition-longer: 0.4s;--transition-long: 0.8s;--transition-bezier: 0.35s cubic-bezier(0.17, 0.89, 0.32, 1.28);--transition-bezier-longer: 0.525s cubic-bezier(0.17, 0.89, 0.32, 1.28);--icon-arrow-square-out: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M228%2C104a12%2C12%2C0%2C0%2C1-24%2C0V69l-59.51%2C59.51a12%2C12%2C0%2C0%2C1-17-17L187%2C52H152a12%2C12%2C0%2C0%2C1%2C0-24h64a12%2C12%2C0%2C0%2C1%2C12%2C12Zm-44%2C24a12%2C12%2C0%2C0%2C0-12%2C12v64H52V84h64a12%2C12%2C0%2C0%2C0%2C0-24H48A20%2C20%2C0%2C0%2C0%2C28%2C80V208a20%2C20%2C0%2C0%2C0%2C20%2C20H176a20%2C20%2C0%2C0%2C0%2C20-20V140A12%2C12%2C0%2C0%2C0%2C184%2C128Z%22/%3E%3C/svg%3E);--icon-caret-down: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M216.49%2C104.49l-80%2C80a12%2C12%2C0%2C0%2C1-17%2C0l-80-80a12%2C12%2C0%2C0%2C1%2C17-17L128%2C159l71.51-71.52a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E);--icon-check: url(data:image/svg+xml,%3Csvg%20role%3D%22img%22%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2232%22%20height%3D%2232%22%20fill%3D%22currentColor%22%20viewBox%3D%220%200%20256%20256%22%3E%3Cpath%20d%3D%22M232.49%2C80.49l-128%2C128a12%2C12%2C0%2C0%2C1-17%2C0l-56-56a12%2C12%2C0%2C1%2C1%2C17-17L96%2C183%2C215.51%2C63.51a12%2C12%2C0%2C0%2C1%2C17%2C17Z%22/%3E%3C/svg%3E);--accent-color: hsl( var(--accent-light-h), var(--accent-light-s), var(--accent-light-l) );--accent-h: var(--accent-light-h);--accent-s: var(--accent-light-s);--accent-l: var(--accent-light-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) ) );--accent-highlight: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--disabled-opacity) );--bg-muted-1: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-1) );--bg-muted-2: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-2) );--l: clamp(0, (l / 0.623 - 1) * -infinity, 1);--a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l))));--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));--shadow-glow: 0 0 0 0.0625rem var(--bg-muted-2), 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--shadow-glow-inset: inset 0 0 0 0.0625rem var(--bg-muted-2), inset 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), inset 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--text-shadow-glow: var(--bg-muted-2) 0 0 0.25rem, var(--accent-color) 0 0 0.75rem;scrollbar-color:var(--accent-color) rgba(0,0,0,0);accent-color:var(--accent-color)}@media(prefers-reduced-motion: no-preference){:root{scroll-behavior:smooth}}@media(prefers-color-scheme: dark){:root{--bg-overlay: linear-gradient( rgba(0, 0, 0, var(--dim-opacity)), rgba(0, 0, 0, var(--dim-opacity)) );--fg-color: #fcfcfa;--fg-contrast: #29242a;--red-fg: hsl(345.18988, 100%, 69%);--red-h: 345.18988;--red-s: 100%;--red-l: 69%;--orange-fg: hsl(19.731544, 96%, 70%);--orange-h: 19.731544;--orange-s: 96%;--orange-l: 70%;--yellow-fg: hsl(44.70588, 100%, 70%);--yellow-h: 44.70588;--yellow-s: 100%;--yellow-l: 70%;--green-fg: hsl(90, 59%, 66%);--green-h: 90;--green-s: 59%;--green-l: 66%;--blue-fg: hsl(186.42857, 71%, 69%);--blue-h: 186.42857;--blue-s: 71%;--blue-l: 69%;--purple-fg: hsl(249.88234, 77%, 78%);--purple-h: 249.88234;--purple-s: 77%;--purple-l: 78%;--red-fg-muted-1: hsla( var(--red-h), var(--red-s), calc(var(--red-l) + (100% - var(--red-l)) * 0.85), var(--fg-muted-1-opacity) );--red-fg-muted-2: hsla( var(--red-h), var(--red-s), calc(var(--red-l) + (100% - var(--red-l)) * 0.85), var(--fg-muted-2-opacity) );--orange-fg-muted-1: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), var(--fg-muted-1-opacity) );--orange-fg-muted-2: hsla( var(--orange-h), var(--orange-s), calc(var(--orange-l) + (100% - var(--orange-l)) * 0.85), var(--fg-muted-2-opacity) );--yellow-fg-muted-1: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), var(--fg-muted-1-opacity) );--yellow-fg-muted-2: hsla( var(--yellow-h), var(--yellow-s), calc(var(--yellow-l) + (100% - var(--yellow-l)) * 0.85), var(--fg-muted-2-opacity) );--green-fg-muted-1: hsla( var(--green-h), var(--green-s), calc(var(--green-l) + (100% - var(--green-l)) * 0.85), var(--fg-muted-1-opacity) );--green-fg-muted-2: hsla( var(--green-h), var(--green-s), calc(var(--green-l) + (100% - var(--green-l)) * 0.85), var(--fg-muted-2-opacity) );--blue-fg-muted-1: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), var(--fg-muted-1-opacity) );--blue-fg-muted-2: hsla( var(--blue-h), var(--blue-s), calc(var(--blue-l) + (100% - var(--blue-l)) * 0.85), var(--fg-muted-2-opacity) );--purple-fg-muted-1: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), var(--fg-muted-1-opacity) );--purple-fg-muted-2: hsla( var(--purple-h), var(--purple-s), calc(var(--purple-l) + (100% - var(--purple-l)) * 0.85), var(--fg-muted-2-opacity) );--shade-color: rgba(0, 0, 0, 0.25);--bg-muted-neutral-1: rgba(255, 255, 255, var(--color-opacity-1));--bg-muted-neutral-2: rgba(255, 255, 255, var(--color-opacity-2));--fg-muted-neutral-1: rgba(255, 255, 255, var(--fg-muted-1-opacity));--fg-muted-neutral-2: rgba(255, 255, 255, var(--fg-muted-2-opacity));--scanlines-opacity: 1;--fg-muted-1-opacity: 0.6;--fg-muted-2-opacity: 0.7;--edge-highlight: inset 0 0.0625rem 0 rgba(255, 255, 255, var(--color-opacity-1));--accent-color: hsl( var(--accent-dark-h), var(--accent-dark-s), var(--accent-dark-l) );--accent-h: var(--accent-dark-h);--accent-s: var(--accent-dark-s);--accent-l: var(--accent-dark-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) - var(--accent-l) * 0.9) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) - var(--accent-l) * 0.8), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.9) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.95) ) );color-scheme:dark}}*,:after,:before{vertical-align:baseline;box-sizing:border-box;margin:0;border:none;background:none;padding:0;color:inherit;font:inherit;-webkit-tap-highlight-color:rgba(0,0,0,0)}button,select{appearance:none}body{-webkit-text-size-adjust:none;text-size-adjust:none;width:100%;min-height:100vh;line-height:1.5}a,button,summary,input[type=button],input[type=checkbox],input[type=color],input[type=file],input[type=image],input[type=radio],input[type=range],input[type=reset],input[type=submit],label,select{cursor:pointer}article,aside,audio,canvas,details,figcaption,figure,footer,header,main,nav,rt,ruby,section,summary{display:block}bdi,mark,meter,output,progress,time{display:inline-block}[hidden],datalist,dialog:not([open]),rp{display:none !important}embed,iframe,img,object,picture,svg,video{display:block;max-width:100%;height:auto}blockquote,q{quotes:none}blockquote:after,blockquote:before,q:after,q:before{content:none}strong{font-weight:700}em{font-style:italic}code,kbd,pre,samp{font-family:monospace}sub{vertical-align:sub}sup{vertical-align:super}small{font-size:small}table{border-collapse:collapse}@keyframes slide-in-up{from{transform:translateY(-1rem);opacity:0}}@keyframes slide-in-left{from{transform:translateX(1rem);opacity:0}}@keyframes slide-in-right{from{transform:translateX(-1rem);opacity:0}}@keyframes slide-in-up-longer{from{transform:translateY(-2rem);opacity:0}}@keyframes slide-in-down-longer{from{transform:translateY(2rem);opacity:0}}@keyframes slide-in-left-longer{from{transform:translateX(-2rem);opacity:0}}@keyframes slide-in-right-longer{from{transform:translateX(2rem);opacity:0}}@keyframes slide-out-down-longer{to{transform:translateY(-2rem);opacity:0}}@keyframes expand-up-left{from{transform:scale(0.5) translate(-1rem, -1rem);opacity:0}}@keyframes expand-up-right{from{transform:scale(0.5) translate(1rem, -1rem);opacity:0}}@keyframes expand-down-left{from{transform:scale(0.5) translate(-1rem, 1rem);opacity:0}}@keyframes expand-down-right{from{transform:scale(0.5) translate(1rem, 1rem);opacity:0}}:target:not(#main-content){scroll-margin-block-start:6.5rem}:focus-visible,:target:not(#main-content){animation:focus-in var(--transition);outline:.125rem solid var(--accent-color);outline-offset:.125rem}@keyframes focus-in{from{outline:.5rem solid rgba(0,0,0,0);outline-offset:.25rem}}::selection{background-color:var(--accent-color);color:var(--contrast-color)}body{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;background-color:var(--bg-color);color:var(--fg-color);font-family:var(--font-sans-serif),var(--font-system-ui),var(--font-emoji);overflow-wrap:break-word}@supports(min-height: 100dvh){body{min-height:100dvh}}@media(prefers-reduced-motion){*,*::before,*::after{animation-duration:0s !important;transition-duration:0s !important}}@-moz-document url-prefix(){@media only screen and (max-width: 960px){.firefox-unblur,.firefox-unblur::before,.firefox-unblur::after{backdrop-filter:none !important}}}body>main,body>nav,body>footer{--padding: 1rem;margin-inline:auto;padding-inline:1rem;max-width:calc(var(--content-width) + var(--padding)*2)}body>footer{margin-block-start:4rem}body>nav{margin-block-end:4rem;border-block-end:0.0625rem solid var(--bg-muted-2)}body>nav ul{display:flex;gap:.25rem;margin-block:.625rem;padding-inline-start:0;list-style:none}@media only screen and (max-width: 480px){body>nav ul{flex-direction:column}}body>nav li{margin-block:0;padding-inline-start:0}p,ul,ol,dl,blockquote,pre{margin-block:1rem}ul ul,ul ol,ol ul,ol ol{margin-block:0}p{text-wrap:pretty;overflow-wrap:break-word}:disabled,[aria-disabled=true]{opacity:var(--disabled-opacity);cursor:not-allowed}h1,h2,h3,h4,h5,h6{transition-duration:var(--transition);transition-property:text-shadow,font-weight;margin-block:2rem 1rem;color:var(--accent-color);font-weight:var(--font-weight-bold);line-height:1;font-variation-settings:"FLAR" 100;letter-spacing:-0.05em;text-decoration:underline;text-decoration-color:var(--bg-muted-1);text-decoration-thickness:.125em;text-wrap:balance;overflow-wrap:break-word}h1:target,h2:target,h3:target,h4:target,h5:target,h6:target{animation:none !important;outline:none !important;font-weight:var(--font-weight-light);text-shadow:var(--text-shadow-glow)}h1{font-size:var(--font-size-xxx-large)}h2{font-size:var(--font-size-xx-large)}h3{font-size:var(--font-size-x-large)}h4{font-size:var(--font-size-large)}h5{font-size:var(--font-size-medium)}h6{font-size:var(--font-size-small)}@media only screen and (max-width: 480px){h1{font-size:var(--font-size-xx-large)}h2{font-size:var(--font-size-x-large)}h3{font-size:var(--font-size-large)}h4{font-size:var(--font-size-medium)}h5{font-size:var(--font-size-small)}h6{font-size:var(--font-size-x-small)}}small{color:var(--fg-muted-2);font-size:var(--font-size-small)}abbr[title]{cursor:help;text-decoration:underline;text-decoration-style:dotted;text-decoration-thickness:0.0625rem}figcaption{color:var(--fg-muted-2);font-size:var(--font-size-small);text-align:center}blockquote{margin:1rem 0;border-inline-start:.25rem solid var(--accent-color);border-start-start-radius:.25rem;border-end-start-radius:.25rem;padding-inline-start:.75rem;color:var(--fg-muted-2)}pre,code,kbd,samp{font-family:var(--font-monospace),var(--font-ui-monospace)}pre{margin:1rem 0;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.75rem 1rem;overflow-x:auto;scrollbar-width:thin;line-height:1.25}code:not(pre code),mark,samp,q{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none}code:not(pre code){background-color:var(--red-bg-1);padding-block:0.1875rem;color:var(--red-fg);font-size:var(--font-size-small)}mark{color:var(--accent-color)}del{color:var(--red-fg);text-decoration:line-through;text-decoration-thickness:0.0625rem}ins{color:var(--green-fg);text-decoration:underline;text-decoration-thickness:0.0625rem}samp{color:var(--fg-muted-2);font-size:var(--font-size-small)}q{color:var(--fg-muted-2);font-style:italic}u{text-decoration:underline;text-decoration-style:wavy;text-decoration-color:var(--red-fg);text-decoration-thickness:0.0625rem}progress{appearance:none;box-shadow:var(--edge-highlight);border:none;border-radius:9999px;background-color:var(--bg-muted-1);padding:.25rem;width:100%;height:1rem;color:var(--accent-color)}progress:indeterminate{background-color:var(--bg-muted-1)}progress:indeterminate::-moz-progress-bar{background-color:rgba(0,0,0,0)}progress::-webkit-progress-bar{background-color:rgba(0,0,0,0)}progress::-moz-progress-bar{border-radius:9999px;background-color:var(--accent-color)}progress::-webkit-progress-value{border-radius:9999px;background-color:var(--accent-color)}kbd{display:inline-block;transition-duration:var(--transition);transition-property:transform,box-shadow,background-color;cursor:pointer;box-shadow:var(--edge-highlight),inset 0 -0.125rem 0 var(--bg-muted-2);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding:0 .375rem;font-size:var(--font-size-small)}kbd:active{transform:translateY(0.125rem);box-shadow:inset 0 0.0625rem 0 var(--bg-muted-2);background-color:var(--bg-muted-2)}a{color:var(--accent-color);font-weight:var(--font-weight-semi-bold);text-decoration-color:var(--accent-highlight);text-decoration-thickness:0.0625rem}a:hover{text-decoration-style:wavy}hr{margin:2rem auto;border:.125rem solid var(--bg-muted-2);border-radius:9999px}dt{font-weight:var(--font-weight-semi-bold)}dd{margin-inline-start:1.5rem;margin-block-end:1rem}aside{float:inline-end;margin-inline-start:1rem;margin-block-end:1rem;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem;width:calc(var(--content-width)/3)}@media only screen and (max-width: 720px){aside{float:none;margin-inline-start:0;width:100%}}aside :first-child{margin-block-start:0}aside :last-child{margin-block-end:0}details{display:flow-root;transition:padding-block-end var(--transition);margin-block-end:1rem;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.75rem 1rem}details::details-content{display:block;opacity:0;transition-behavior:allow-discrete;transition-duration:var(--transition);transition-property:opacity,block-size,content-visibility;block-size:0;overflow:hidden}details[open]{padding-block-end:1rem}details[open]::details-content{opacity:1;block-size:auto;block-size:calc-size(auto,size)}details[open] summary{color:var(--accent-color)}details[open] summary::before{transform:scaleY(-1)}@supports not (block-size: calc-size(auto, size)){details[open] summary~*{animation:slide-in-up var(--transition-bezier)}}details summary{transition:var(--transition);color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);list-style:none}details summary::-webkit-details-marker{display:none}details summary::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-caret-down);display:inline-block;vertical-align:-0.125rem;transition:all var(--transition),transform var(--transition-bezier);margin-inline-end:.25rem}details>:last-child,details>:last-child img{margin-block-end:0}figure{margin-inline:0}ol,ul{padding-inline-start:1.5rem}li{margin-block:.25rem;padding-inline-start:.25rem}li::marker{color:var(--accent-color);font-weight:var(--font-weight-semi-bold)}b,strong{font-weight:var(--font-weight-semi-bold)}.buttons{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.25rem;margin-block-start:2rem}.buttons.centered{justify-content:center}.buttons.start{justify-content:flex-start}.buttons.end{justify-content:flex-end}.buttons.big{gap:.5rem}.buttons a,a.button,button{display:inline-block;transition:background-color var(--transition),color var(--transition),box-shadow var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.5rem 1rem;color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-small);line-height:1.5rem;text-decoration:none}.buttons a:not(:disabled,[aria-disabled=true]):hover,a.button:not(:disabled,[aria-disabled=true]):hover,button:not(:disabled,[aria-disabled=true]):hover{background-color:var(--bg-muted-2);color:var(--fg-muted-2)}.buttons a:not(:disabled,[aria-disabled=true]):active,a.button:not(:disabled,[aria-disabled=true]):active,button:not(:disabled,[aria-disabled=true]):active{transform:var(--active)}.buttons a.suggested,a.button.suggested,button.suggested{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow)}.buttons a.suggested:not(:disabled,[aria-disabled=true]):hover,a.button.suggested:not(:disabled,[aria-disabled=true]):hover,button.suggested:not(:disabled,[aria-disabled=true]):hover{box-shadow:var(--edge-highlight);background-color:var(--accent-color);color:var(--contrast-color)}.buttons a.subtle,a.button.subtle,button.subtle{box-shadow:none;background-color:rgba(0,0,0,0)}.buttons a.subtle:not(:disabled,[aria-disabled=true]):hover,a.button.subtle:not(:disabled,[aria-disabled=true]):hover,button.subtle:not(:disabled,[aria-disabled=true]):hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}.buttons a[aria-busy=true],a.button[aria-busy=true],button[aria-busy=true]{animation:progress-shimmer var(--transition-long) ease-in-out alternate infinite;background-image:linear-gradient(to right, transparent 50%, var(--bg-muted-1) 75%, transparent 100%);background-size:200%}@keyframes progress-shimmer{to{background-position-x:-200%}}.buttons.big a,.buttons.big button,a.button.big,button.big{border-radius:9999px;padding:.625rem 1.25rem}.card{box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem}.crt,pre:has(code[data-crt],code[data-lang=""]){position:relative;animation:flicker .25s alternate infinite;margin-inline:auto;box-shadow:var(--shadow-glow);background-image:var(--crt-bg);background-color:rgba(0,0,0,0) !important;padding:0;width:fit-content;max-width:100%;overflow:hidden;color:var(--accent-color) !important;font-weight:var(--font-weight-medium);text-shadow:var(--text-shadow-glow)}@keyframes flicker{25%{opacity:.95}50%{opacity:.85}75%{opacity:1}to{opacity:.9}}.crt::before,.crt::after,pre:has(code[data-crt],code[data-lang=""])::before,pre:has(code[data-crt],code[data-lang=""])::after{position:absolute;inset:0;content:""}.crt::before,pre:has(code[data-crt],code[data-lang=""])::before{opacity:var(--scanlines-opacity);z-index:1;animation:scanlines .1s linear infinite;background-image:repeating-linear-gradient(to bottom, rgba(0, 0, 0, 0.25), rgba(0, 0, 0, 0.25) 0.125rem, transparent 0.125rem, transparent 0.25rem);background-position-y:-0.125rem;background-repeat:no-repeat;pointer-events:none}@keyframes scanlines{to{background-position-y:.125rem}}.crt::after,pre:has(code[data-crt],code[data-lang=""])::after{opacity:.05;animation:scanline 5s linear infinite;background-image:linear-gradient(to bottom, transparent, var(--accent-color) 16rem);background-position-y:-16rem;background-size:100% 16rem;pointer-events:none}@keyframes scanline{to{background-position-y:calc(100% + 16rem)}}.crt>code,pre:has(code[data-crt],code[data-lang=""])>code{display:block;border-radius:inherit;padding:.75rem 1rem;overflow-x:auto;scrollbar-width:thin}a.external:not(:has(img,svg,video,picture,figure))::after{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-arrow-square-out);display:inline-block;vertical-align:-0.125rem;opacity:var(--dim-opacity);margin-inline-start:.125rem;font-size:max(1rem,1em)}iframe{display:block;margin:1rem auto;box-shadow:var(--edge-highlight),var(--shadow);border:none;border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);width:100%;max-width:100%}iframe:fullscreen{box-shadow:none;border-radius:0}iframe:-webkit-full-screen{box-shadow:none;border-radius:0}input{appearance:none;background-color:var(--bg-muted-1)}input:not([type]),input[type=text],input[type=search],input[type=password],input[type=submit]{box-shadow:var(--edge-highlight);border:none;border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);font-size:inherit;line-height:inherit;font-family:inherit}input:not([type]),input[type=text],input[type=search],input[type=password]{padding:.5rem .75rem;width:100%}input:not([type])::placeholder,input[type=text]::placeholder,input[type=search]::placeholder,input[type=password]::placeholder{opacity:1;color:var(--fg-muted-1)}input[type=submit]{transition:background-color var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner);padding:.375rem .75rem;color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-small)}input[type=submit]:enabled:hover{background-color:var(--bg-muted-2)}input[type=submit]:enabled:active{transform:var(--active)}input[type=radio],input[type=checkbox],input[type=color]{position:relative;transition:background-color var(--transition);border:.125rem solid var(--bg-muted-2);width:1rem;height:1rem}input[type=radio]:not(:checked):enabled:hover,input[type=checkbox]:not(:checked):enabled:hover,input[type=color]:not(:checked):enabled:hover{background-color:var(--bg-muted-2)}input[type=radio],input[type=checkbox]{vertical-align:-0.125em;margin-inline-end:.25rem}input[type=radio]::before,input[type=checkbox]::before{display:block;position:absolute;transform:scale(0.5);opacity:0;transition:opacity var(--transition),transform var(--transition-bezier);content:""}input[type=radio]:checked,input[type=checkbox]:checked{border:.15rem solid rgba(0,0,0,0);background-color:var(--accent-color)}input[type=radio]:checked::before,input[type=checkbox]:checked::before{transform:scale(1);opacity:1}input[type=radio]{border-radius:50%}input[type=radio]::before{inset-block-start:.125rem;inset-inline-start:.125rem;border-radius:50%;background-color:var(--contrast-color);width:.5rem;height:.5rem}input[type=checkbox]{border-radius:calc(var(--rounded-corner-small)/2)}input[type=checkbox]::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-check);transform-origin:bottom left;inset-block-start:-0.125rem;inset-inline-start:-0.125rem;color:var(--contrast-color);font-size:1rem}input[type=checkbox].switch{vertical-align:-0.375rem;box-shadow:var(--edge-highlight);border:none;border-radius:9999px;width:2.5rem;height:1.5rem}input[type=checkbox].switch.big{vertical-align:-0.625rem;width:3rem;height:2rem}input[type=checkbox].switch.big::before{width:1.5rem;height:1.5rem}input[type=checkbox].switch::before{transform:none;transform-origin:center;opacity:1;mask-image:none;inset-block-start:.25rem;inset-inline-start:.25rem;box-shadow:var(--shadow);border-radius:50%;background-color:#fff;width:1rem;height:1rem;content:""}input[type=checkbox].switch:checked{background-color:var(--accent-color)}input[type=checkbox].switch:checked::before{transform:translateX(1rem);background-color:var(--contrast-color)}:root[dir*=rtl] input[type=checkbox].switch:checked::before{transform:translateX(-1rem)}input[type=checkbox].switch:disabled::before{box-shadow:none}input[type=color]{vertical-align:-0.375em;box-shadow:var(--edge-highlight);border:none;border-radius:var(--rounded-corner-small);padding:.25rem;width:3rem;height:2rem}input[type=color]::-moz-color-swatch{border:none;border-radius:calc(var(--rounded-corner-small) - .25rem)}input[type=color]::-webkit-color-swatch-wrapper{padding:0}input[type=color]::-webkit-color-swatch{border-radius:calc(var(--rounded-corner-small) - .25rem)}input[type=range]{appearance:none;box-shadow:var(--edge-highlight);border-radius:9999px;background:var(--bg-muted-1);padding:.5rem .25rem;width:100%;height:1rem}input[type=range]::-webkit-slider-runnable-track{cursor:pointer;border-radius:9999px;background:var(--accent-color);width:100%}input[type=range]::-moz-range-track{cursor:pointer;border-radius:9999px;background:var(--accent-color);width:100%;height:.5rem}input[type=range]::-webkit-slider-thumb{appearance:none;cursor:ew-resize;margin-block:-0.5rem;box-shadow:var(--shadow);border:none;border-radius:.25rem;background-color:#fff;width:.5rem;height:1.5rem}input[type=range]::-moz-range-thumb{appearance:none;cursor:ew-resize;margin-block:-0.5rem;box-shadow:var(--shadow);border:none;border-radius:.25rem;background-color:#fff;width:.5rem;height:1.5rem}input[type=range].hollow::-webkit-slider-thumb{margin-block:0;outline:.25rem solid #fff;background-color:rgba(0,0,0,0);height:.5rem}input[type=range].hollow::-moz-range-thumb{margin-block:0;outline:.25rem solid #fff;background-color:rgba(0,0,0,0);height:.5rem}img,video{display:block;margin:1rem auto;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);max-width:100%;max-height:calc(100vh - 2rem)}img.full,img[src*="#full"],video.full,video[src*="#full"]{width:100%}img.start,img.end,img[src*="#start"],img[src*="#end"],video.start,video.end,video[src*="#start"],video[src*="#end"]{width:calc(var(--content-width)/3)}img.start,img[src*="#start"],video.start,video[src*="#start"]{float:inline-start;transform-origin:left;margin-inline-end:1rem}:root[dir*=rtl] img.start,:root[dir*=rtl] img[src*="#start"],:root[dir*=rtl] video.start,:root[dir*=rtl] video[src*="#start"]{transform-origin:right}img.end,img[src*="#end"],video.end,video[src*="#end"]{float:inline-end;transform-origin:right;margin-inline-start:1rem}:root[dir*=rtl] img.end,:root[dir*=rtl] img[src*="#end"],:root[dir*=rtl] video.end,:root[dir*=rtl] video[src*="#end"]{transform-origin:left}img.pixels,img[src*="#pixels"],video.pixels,video[src*="#pixels"]{image-rendering:pixelated}img.has-alpha,img[src*="#has-alpha"],video.has-alpha,video[src*="#has-alpha"]{box-shadow:none;border-radius:0;background-color:rgba(0,0,0,0)}img.drop-shadow,img[src*="#drop-shadow"],video.drop-shadow,video[src*="#drop-shadow"]{filter:var(--drop-shadow)}img.spoiler,img[src*="#spoiler"],video.spoiler,video[src*="#spoiler"]{opacity:var(--dim-opacity);clip-path:inset(0 0 0 0 round var(--rounded-corner));filter:blur(1.5rem)}img.spoiler:hover,img.spoiler:active,img[src*="#spoiler"]:hover,img[src*="#spoiler"]:active,video.spoiler:hover,video.spoiler:active,video[src*="#spoiler"]:hover,video[src*="#spoiler"]:active{opacity:1;clip-path:inset(-0.75rem -0.75rem -0.75rem -0.75rem round var(--rounded-corner-small));filter:none}img.spoiler.solid,img.spoiler[src*="#solid"],img[src*="#spoiler"].solid,img[src*="#spoiler"][src*="#solid"],video.spoiler.solid,video.spoiler[src*="#solid"],video[src*="#spoiler"].solid,video[src*="#spoiler"][src*="#solid"]{clip-path:none;filter:brightness(0) contrast(0.5);box-shadow:none}img.spoiler.solid:hover,img.spoiler.solid:active,img.spoiler[src*="#solid"]:hover,img.spoiler[src*="#solid"]:active,img[src*="#spoiler"].solid:hover,img[src*="#spoiler"].solid:active,img[src*="#spoiler"][src*="#solid"]:hover,img[src*="#spoiler"][src*="#solid"]:active,video.spoiler.solid:hover,video.spoiler.solid:active,video.spoiler[src*="#solid"]:hover,video.spoiler[src*="#solid"]:active,video[src*="#spoiler"].solid:hover,video[src*="#spoiler"].solid:active,video[src*="#spoiler"][src*="#solid"]:hover,video[src*="#spoiler"][src*="#solid"]:active{filter:none}@media only screen and (max-width: 720px){img.start,img.end,img[src*="#start"],img[src*="#end"],video.start,video.end,video[src*="#start"],video[src*="#end"]{float:none;margin-inline-start:auto;margin-inline-end:auto;width:auto}}img{transition:box-shadow var(--transition),border-radius var(--transition),transform var(--transition-bezier)}img:not(.no-hover,[src*="#no-hover"],.emoji){cursor:zoom-in}img:not(.no-hover,[src*="#no-hover"],.emoji):hover{position:relative;transform:var(--hover);z-index:1;box-shadow:var(--edge-highlight),var(--shadow-raised);border-radius:var(--rounded-corner-small)}img:not(.no-hover,[src*="#no-hover"],.emoji).start,img:not(.no-hover,[src*="#no-hover"],.emoji).end,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"],img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]{transition:transform var(--transition-bezier-longer)}img:not(.no-hover,[src*="#no-hover"],.emoji).start:hover,img:not(.no-hover,[src*="#no-hover"],.emoji).end:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"]:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]:hover{transform:scale(2)}@media only screen and (max-width: 720px){img:not(.no-hover,[src*="#no-hover"],.emoji).start,img:not(.no-hover,[src*="#no-hover"],.emoji).end,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"],img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]{transform-origin:center;transition:transform var(--transition-bezier)}img:not(.no-hover,[src*="#no-hover"],.emoji).start:hover,img:not(.no-hover,[src*="#no-hover"],.emoji).end:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#start"]:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#end"]:hover{transform:var(--hover)}}img:not(.no-hover,[src*="#no-hover"],.emoji).has-alpha:hover,img:not(.no-hover,[src*="#no-hover"],.emoji)[src*="#has-alpha"]:hover{box-shadow:none}a img:not(.no-hover,[src*="#no-hover"]){cursor:pointer}video:fullscreen{box-shadow:none;border-radius:0}video:-webkit-full-screen{box-shadow:none;border-radius:0}picture{display:inline-block}.overflow-auto{overflow:auto}.overflow-auto.overshoot{margin-block:-1rem;padding-block:1rem}.overflow-auto.overshoot-row{margin-inline:-1rem;padding-inline:1rem}.overshoot{--mask: linear-gradient( to bottom, transparent, black 1rem, black calc(100% - 1rem), transparent );-webkit-mask-image:var(--mask);mask-image:var(--mask)}.overshoot-row{--mask: linear-gradient( to right, transparent, black 1rem, black calc(100% - 1rem), transparent );-webkit-mask-image:var(--mask);mask-image:var(--mask)}#skip-to-content{position:absolute;transform:translateX(-50%) translateY(calc(-100% - 1rem));z-index:9999;transition:backdrop-filter var(--transition),box-shadow var(--transition),transform var(--transition-bezier);inset-inline-start:50vw;border-radius:9999px;background-color:var(--glass-bg-1);padding:.375rem .75rem;width:max-content;text-decoration:none}#skip-to-content:focus-visible,#skip-to-content:focus{-webkit-backdrop-filter:var(--blur);transform:translateX(-50%) translateY(1rem);backdrop-filter:var(--blur);box-shadow:var(--edge-highlight),var(--shadow)}span.spoiler{filter:blur(0.25rem);transition:filter var(--transition)}span.spoiler:focus,span.spoiler:hover{filter:none}span.spoiler.solid{filter:none;transition:background-color var(--transition),color var(--transition);border-radius:var(--rounded-corner-small);background-color:var(--fg-muted-1);color:rgba(0,0,0,0)}span.spoiler.solid:focus,span.spoiler.solid:hover{background-color:rgba(0,0,0,0);color:inherit}table{width:100%;overflow:hidden}thead{border-block-start:none;color:var(--fg-muted-2);font-weight:var(--font-weight-semi-bold)}tr:nth-child(even){background-color:var(--bg-muted-1)}tr th,tr td{padding:.5rem 1rem}tbody tr{border-block:0.0625rem solid var(--bg-muted-2)}tbody tr:last-child{border-block-end:none}.overflow-auto table{width:max-content;min-width:100%}textarea{appearance:none;box-shadow:var(--edge-highlight);border:none;border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem;resize:none;font-size:inherit;line-height:inherit;font-family:inherit}textarea::placeholder{opacity:1;color:var(--fg-muted-1)}strong.title{color:var(--accent-color);font-size:var(--font-size-large);line-height:1}@media(pointer: coarse),(hover: none){.tooltip,abbr[title]{display:inline-flex;position:relative;justify-content:center}.tooltip::after,abbr[title]::after{position:absolute;top:calc(100% + .25rem);z-index:9999;backdrop-filter:var(--blur);transition:visibility var(--transition),opacity var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner-small);background-color:var(--glass-bg-1);padding:.125rem .375rem;width:max-content;content:attr(title);-webkit-backdrop-filter:var(--blur);transform:translateY(-0.5rem) scale(0.5);transform-origin:top;visibility:hidden;opacity:0;max-width:calc(var(--content-width)/3);color:var(--fg-muted-2);font-size:var(--font-size-small)}.tooltip:focus::after,.tooltip:hover::after,abbr[title]:focus::after,abbr[title]:hover::after{transform:none;visibility:visible;opacity:1}}.visually-hidden{position:absolute !important;clip-path:inset(50%) !important;margin:-1px !important;border-width:0 !important;padding:0 !important;width:1px !important;height:1px !important;overflow:hidden !important;white-space:nowrap !important}#site-footer{--padding: 1rem;display:flex;grid-area:footer;gap:1rem;margin-block:3.25rem 1rem;border-block-start:0.0625rem solid var(--bg-muted-2);padding:1rem;width:100%;min-width:0;max-width:calc(var(--content-width) + var(--padding)*2)}@media only screen and (max-width: 480px){#site-footer{flex-direction:column-reverse}}#site-footer>div{display:flex;flex:1;flex-direction:column;gap:1rem}#site-footer>div p{margin:0}#site-footer>div:nth-of-type(1){color:var(--fg-muted-2);font-size:var(--font-size-small)}#site-footer>div:nth-of-type(2){justify-content:space-between;align-items:flex-end;text-align:end}@media only screen and (max-width: 480px){#site-footer>div:nth-of-type(2){align-items:normal;text-align:start}}#site-footer a.external::after{margin-inline-start:.25rem}#site-footer .link{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none;display:inline-block;line-height:1.5rem}#site-footer .link:hover{background-color:var(--bg-muted-2)}#site-footer .link:active{transform:var(--active)}#site-footer .webrings{display:flex;flex-direction:column;gap:.25rem;margin-block:0;padding-inline-start:0;list-style:none}#site-footer .webring{display:flex;gap:.25rem;margin-block:0;padding-inline-start:0}#site-footer .webring .random,#site-footer .webring .previous,#site-footer .webring .next{display:inline-flex;justify-content:center;align-items:center;border-radius:9999px;padding:.25rem;font-size:1rem}#site-footer .timestamp{color:var(--fg-muted-1);font-size:var(--font-size-xx-small);font-family:var(--font-monospace);text-transform:uppercase}#site-footer .socials{display:flex;justify-content:flex-end;gap:.75rem;margin:0;padding-inline-start:0;list-style:none}@media only screen and (max-width: 480px){#site-footer .socials{justify-content:normal}}#site-footer .socials li{margin-block:0;padding-inline-start:0}#site-footer .socials a{display:flex;transition:color var(--transition),transform var(--transition-bezier);height:fit-content;color:var(--fg-muted-1)}#site-footer .socials a:hover{color:var(--fg-muted-2)}#site-footer .socials a:active{transform:var(--active)}#site-footer .socials svg{fill:currentColor;width:1.5rem;height:1.5rem}.full-bleed{transform:translateX(-50%);margin-inline-start:50%;width:calc(100vw - var(--sidebar-width))}:root[dir*=rtl] .full-bleed{transform:translateX(50%)}@media only screen and (max-width: 960px){.full-bleed{width:calc(100vw - 3.5rem)}}body{display:grid;grid-template-rows:auto auto 1fr auto;grid-template-columns:var(--sidebar-width) 1fr;grid-template-areas:"sidebar header" "sidebar banner" "sidebar main " "sidebar footer";justify-items:center;transition:grid-template-columns var(--transition)}@media only screen and (max-width: 960px){body{grid-template-columns:0 1fr}}#site-header{display:flex;position:sticky;grid-area:header;justify-content:space-between;align-items:center;gap:.25rem;z-index:2;inset-block-start:0;padding:.5rem;width:100%;min-width:0}#site-header::before,#site-header::after{position:absolute;inset:0;--mask-image: linear-gradient( to bottom, hsl(0, 0%, 0%) 0%, hsla(0, 0%, 0%, 0.987) 8.1%, hsla(0, 0%, 0%, 0.951) 15.5%, hsla(0, 0%, 0%, 0.896) 22.5%, hsla(0, 0%, 0%, 0.825) 29%, hsla(0, 0%, 0%, 0.741) 35.3%, hsla(0, 0%, 0%, 0.648) 41.2%, hsla(0, 0%, 0%, 0.55) 47.1%, hsla(0, 0%, 0%, 0.45) 52.9%, hsla(0, 0%, 0%, 0.352) 58.8%, hsla(0, 0%, 0%, 0.259) 64.7%, hsla(0, 0%, 0%, 0.175) 71%, hsla(0, 0%, 0%, 0.104) 77.5%, hsla(0, 0%, 0%, 0.049) 84.5%, hsla(0, 0%, 0%, 0.013) 91.9%, hsla(0, 0%, 0%, 0) 100% );mask-image:var(--mask-image);-webkit-mask-image:var(--mask-image);inset-block-end:-100%;pointer-events:none;content:""}#site-header::before{z-index:-2;backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur)}#site-header::after{z-index:-1;background-color:var(--bg-color)}#site-header .title{margin-inline:.75rem;min-width:0;overflow:hidden;color:var(--fg-muted-2);font-size:var(--font-size-small);line-height:2rem;text-overflow:ellipsis;white-space:nowrap}#site-header .buttons{display:flex;flex-wrap:nowrap;gap:.25rem;margin:0;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:1.125rem;background-color:var(--glass-bg-1);padding:.25rem;list-style:none}#site-header .buttons li{flex:0;margin-block:0;padding-inline-start:0}#site-header .buttons>li{height:fit-content;line-height:0}#site-header .buttons button,#site-header .buttons details{box-shadow:none;background-color:rgba(0,0,0,0);line-height:inherit}#site-header .buttons button .icon,#site-header .buttons details .icon{margin-inline-end:0;box-shadow:none;background-color:rgba(0,0,0,0);padding:0}#site-header .buttons button,#site-header .buttons summary{border-radius:50%;padding:.375rem}#site-header .buttons button:hover,#site-header .buttons summary:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#site-header .buttons button:active,#site-header .buttons summary:active{transform:var(--active)}#site-header .buttons details{box-shadow:none;border-radius:0;padding:0}#site-header .buttons button{font-size:var(--font-size-medium)}#site-header .buttons summary:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1)}#site-header .buttons summary::before{display:none}#site-header .buttons details{display:block;position:relative;margin-block-end:0}#site-header .buttons details::details-content{block-size:auto}#site-header .buttons details[open]::details-content{block-size:auto}#site-header .buttons details[open] summary{color:var(--fg-muted-1)}#site-header .buttons details[open] summary:hover{color:var(--fg-muted-2)}@supports not (block-size: calc-size(auto, size)){#site-header .buttons details[open] summary~*{animation:none}}.buttons .dropdown{display:flex;position:absolute;flex-direction:column;gap:.25rem;transform-origin:top right;z-index:1;backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);animation:expand-up-right var(--transition-bezier);inset-block-start:calc(100% + .75rem);inset-inline-end:-0.25rem;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:calc(var(--rounded-corner) + .25rem);background-color:var(--glass-bg-1);padding:.25rem;width:max-content;min-width:8rem;list-style:none}:root[dir*=rtl] .buttons .dropdown{transform-origin:top left;animation:expand-up-left var(--transition-bezier)}.buttons .dropdown li{flex:1}.buttons .dropdown a{box-shadow:none;border-radius:var(--rounded-corner);background-color:rgba(0,0,0,0);padding:.375rem .75rem;width:100%}.buttons .dropdown a:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1) !important}#site-sidebar-controls{display:flex;flex-shrink:0;gap:.25rem;transition:width var(--transition),visibility var(--transition);margin-block:0;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:1.125rem;background-color:var(--glass-bg-1);padding:.25rem;height:fit-content;line-height:0;list-style:none}@media only screen and (max-width: 960px){#site-sidebar-controls{width:2.25rem}#site-sidebar-controls:has(#expand-sidebar:checked){width:6.25rem}#site-sidebar-controls:has(#expand-sidebar:checked) #site-sidebar-modes{transform:none;visibility:visible;opacity:1}}#site-sidebar-controls ul{padding-inline-start:0;list-style:none}#site-sidebar-controls li{display:inline-block;margin-block:0;padding-inline-start:0}#site-sidebar-controls li label{display:inline-block;transition:box-shadow var(--transition),background-color var(--transition),color var(--transition),transform var(--transition-bezier);cursor:pointer;border-radius:50%;padding:.375rem;height:fit-content;color:var(--fg-muted-1);line-height:0}#site-sidebar-controls li label:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#site-sidebar-controls li label:active{transform:var(--active)}#site-sidebar-controls li input[type=radio]+label{position:relative;z-index:1}#site-sidebar-controls li input[type=radio]+label::before{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow);position:absolute;opacity:0;z-index:-1;transition:opacity var(--transition),box-shadow var(--transition),background-color var(--transition);inset:0;border-radius:inherit;content:""}#site-sidebar-controls li input[type=radio]:checked+label{color:var(--contrast-color)}#site-sidebar-controls li input[type=radio]:checked+label:hover{box-shadow:none;background-color:rgba(0,0,0,0)}#site-sidebar-controls li input[type=radio]:checked+label:hover::before{box-shadow:var(--edge-highlight);background-color:var(--accent-color)}#site-sidebar-controls li input[type=radio]:checked+label::before{opacity:1}#site-sidebar-controls li input[type=radio]:focus-visible+label,#site-sidebar-controls li input[type=checkbox]:focus-visible+label{animation:focus-in var(--transition);outline:.125rem solid var(--accent-color);outline-offset:.125rem}@supports not selector(:focus-visible){#site-sidebar-controls li input[type=radio]:focus+label,#site-sidebar-controls li input[type=checkbox]:focus+label{animation:focus-in var(--transition);outline:.125rem solid var(--accent-color);outline-offset:.125rem}}#site-sidebar-controls #expand-sidebar-li{display:none}@media only screen and (max-width: 960px){#site-sidebar-controls #expand-sidebar-li{display:inline-block}}#site-sidebar-modes{display:flex;gap:.25rem;transform-origin:left;transition:visibility var(--transition),opacity var(--transition),transform var(--transition)}@media only screen and (max-width: 960px){#site-sidebar-modes{transform:translateX(-2.25rem);visibility:hidden;opacity:0}}#main-content{--padding: 1rem;grid-area:main;transform-origin:50% 50vh;animation:fade-in-zoom var(--transition-bezier-longer);margin-block-start:3.25rem;padding-inline:1rem;width:100%;min-width:0;max-width:calc(var(--content-width) + var(--padding)*2)}@supports(transform-origin: 50% 50dvh){#main-content{transform-origin:50% 50dvh}}#main-content>:first-child{margin-block-start:0}#main-content>:last-child{margin-block-end:0}@keyframes fade-in-zoom{from{transform:scale(0.95);opacity:0}}:target~#main-content{animation-name:none}#↑:target~#main-content,#up:target~#main-content{animation-name:slide-in-up-longer}#↓:target~#main-content,#down:target~#main-content{animation-name:slide-in-down-longer}#←:target~#main-content,#left:target~#main-content{animation-name:slide-in-left-longer}#→:target~#main-content,#right:target~#main-content{animation-name:slide-in-right-longer}#site-sidebar{display:flex;position:sticky;grid-area:sidebar;flex-direction:column;z-index:3;transition-duration:var(--transition);transition-property:visibility,transform,inset-block-start,box-shadow,border-inline-end,border-start-end-radius,border-end-end-radius,height;inset-block-start:0;inset-inline-start:0;border-inline-end:0.0625rem solid var(--bg-muted-2);width:var(--sidebar-width);height:100vh}@supports(height: 100dvh){#site-sidebar{height:100dvh}}@media only screen and (max-width: 960px){#site-sidebar{transform:translateX(-100%);visibility:hidden;inset-block-start:3.25rem;box-shadow:var(--edge-highlight),var(--shadow);border-inline-end:0 solid rgba(0,0,0,0);border-start-end-radius:var(--rounded-corner);border-end-end-radius:var(--rounded-corner);height:calc(100vh - 3.75rem)}:root[dir*=rtl] #site-sidebar{transform:translateX(100%)}@supports(height: 100dvh){#site-sidebar{height:calc(100dvh - 3.75rem)}}#site-header:has(#expand-sidebar:checked)~#site-sidebar{transform:none;visibility:visible;backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);background-color:var(--glass-bg-1)}}#site-sidebar-content{display:flex;position:relative;flex:1;overflow:hidden}#site-sidebar-header{display:flex;align-items:center;transform-origin:1.375rem 50%;transition:box-shadow var(--transition),background-color var(--transition),color var(--transition),transform var(--transition-bezier);margin:1rem;margin-block-end:0;border-radius:1.375rem;padding:.25rem;padding-inline-end:.75rem;min-width:0;color:var(--fg-muted-1);text-decoration:none}#site-sidebar-header:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#site-sidebar-header:hover .icon{background-color:var(--bg-muted-2)}#site-sidebar-header:active{transform:var(--active)}#site-sidebar-header img{margin:0}#site-sidebar-header img,#site-sidebar-header .icon{margin-inline-end:.5rem;border-radius:50%}#site-sidebar-header img{width:2.25rem;height:2.25rem}#site-sidebar-header .icon{transition:background-color var(--transition);box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);padding:.5rem;font-size:1.25rem}#site-sidebar-header div{display:flex;flex:1;flex-direction:column;gap:.125rem;min-width:0;line-height:1.25}#site-sidebar-header div span:nth-of-type(1){overflow:hidden;font-size:var(--font-size-small);text-overflow:ellipsis;white-space:nowrap}#site-sidebar-header div span:nth-of-type(2){font-weight:var(--font-weight-normal);font-size:var(--font-size-x-small)}#sidebar-nav,#sidebar-meta{display:flex;flex:1;flex-direction:column;transition:visibility var(--transition),opacity var(--transition),filter var(--transition),transform var(--transition-bezier);padding:1rem;width:var(--sidebar-width);min-width:100%;overflow:hidden auto;scrollbar-width:thin}#site-header:has(#site-sidebar-modes #mode-meta:checked)+#site-sidebar #sidebar-nav{transform:perspective(var(--content-width)) rotateY(15deg);opacity:var(--disabled-opacity);filter:blur(0.25rem)}#site-header:has(#site-sidebar-modes #mode-meta:checked)+#site-sidebar #sidebar-meta{transform:perspective(var(--content-width));visibility:visible;opacity:1;filter:none}#sidebar-nav{transform:perspective(var(--content-width));transform-origin:left}:root[dir*=rtl] #sidebar-nav{transform-origin:right}#sidebar-nav a{display:flex;flex:1;align-items:center;transform-origin:1.375rem 50%;transition:box-shadow var(--transition),background-color var(--transition),color var(--transition),transform var(--transition-bezier);border-radius:1.375rem;padding:.25rem;padding-inline-end:.75rem;overflow:hidden;color:var(--fg-muted-1);text-decoration:none;text-overflow:ellipsis;white-space:nowrap}#sidebar-nav a:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--fg-muted-2)}#sidebar-nav a:hover .icon{background-color:var(--bg-muted-2)}#sidebar-nav a:active{transform:var(--active)}#sidebar-nav a[aria-current=page]{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-1);color:var(--accent-color)}#sidebar-nav a[aria-current=page] .icon{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow);text-shadow:none}#sidebar-nav a[aria-current=page]:hover{background-color:var(--bg-muted-2)}#sidebar-nav a[aria-current=page]:hover .icon{box-shadow:var(--edge-highlight);background-color:var(--accent-color);color:var(--contrast-color)}#sidebar-nav a.new .icon{position:relative}#sidebar-nav a.new .icon::after{position:absolute;inset-block-start:0.0625rem;inset-inline-end:0.0625rem;border-radius:50%;background-color:var(--accent-color);width:.75rem;height:.75rem;content:""}#sidebar-nav .icon{transition:background-color var(--transition),color var(--transition),box-shadow var(--transition);margin-inline-end:.5rem;box-shadow:var(--edge-highlight);border-radius:50%;background-color:var(--bg-muted-1);padding:.5rem;font-size:1.25rem}#sidebar-nav .icon.fallback{width:1.25rem;font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-medium);line-height:1.25rem;font-family:var(--font-monospace);text-align:center}#sidebar-nav .icon.fallback::before{content:none}#sidebar-nav details{flex:1;margin-block-end:0;box-shadow:none;border-radius:0;background-color:rgba(0,0,0,0);padding:0}#sidebar-nav details[open] summary{transform:none;opacity:1;box-shadow:none;background-color:rgba(0,0,0,0)}#sidebar-nav details[open] summary::before{transform:none}@supports not (block-size: calc-size(auto, size)){#sidebar-nav details[open] summary~*{animation:none}}#sidebar-nav details summary{display:flex;position:relative;align-items:center;border-radius:0;padding:.75rem 0;color:var(--fg-muted-1);font-weight:var(--font-weight-normal);font-size:var(--font-size-small);line-height:var(--font-size-small);white-space:nowrap}#sidebar-nav details summary:hover{box-shadow:none;background-color:rgba(0,0,0,0)}#sidebar-nav details summary:active{transform:none}#sidebar-nav details summary::before{position:absolute;transform:rotate(-90deg);transition:transform var(--transition-bezier);margin-inline-end:0;inset-block:.5rem;inset-inline-end:0;font-size:1rem}#sidebar-nav details summary .line{flex:1;margin-inline:.5rem 1.5rem;background-color:var(--bg-muted-2);height:0.0625rem}#sidebar-nav details ul{margin-block:0}#sidebar-nav ul{display:flex;flex-direction:column;gap:.25rem;margin-block:auto;padding-inline-start:0;list-style:none}#sidebar-nav li{display:flex;transition:width var(--transition);margin-block:0;padding-inline-start:0;width:100%}#sidebar-meta{position:absolute;transform:perspective(var(--content-width)) rotateY(-30deg);transform-origin:left;visibility:hidden;opacity:0;filter:blur(0.25rem);inset:0}:root[dir*=rtl] #sidebar-meta{transform-origin:right}#sidebar-meta:has(>div:empty) .status-page{display:flex}#sidebar-meta:has(>div:empty)>div{display:none}#sidebar-meta>div{display:flex;flex-direction:column;gap:1rem;margin-block:auto}#sidebar-meta .card{background-color:var(--glass-bg-1)}#sidebar-meta .while-writing{display:inherit;position:relative;flex-direction:inherit;gap:inherit;margin:calc(-0.25rem - 0.0625rem);margin-block-start:1rem;border:0.0625rem solid var(--bg-muted-2);border-block-start:none;border-end-start-radius:calc(var(--rounded-corner) + .25rem + 0.0625rem);border-end-end-radius:calc(var(--rounded-corner) + .25rem + 0.0625rem);padding:.25rem}#sidebar-meta .while-writing .card-title{display:flex;position:absolute;gap:.25rem;inset-block-start:calc(var(--rounded-corner-small)*-1);inset-inline:-0.0625rem;height:var(--rounded-corner-small)}#sidebar-meta .while-writing .card-title .line{border:0.0625rem solid var(--bg-muted-2);border-block-end:none}#sidebar-meta .while-writing .card-title .line:nth-of-type(1){border-inline-end:none;border-start-start-radius:var(--rounded-corner);width:var(--rounded-corner)}#sidebar-meta .while-writing .card-title .line:nth-of-type(2){flex:1;border-inline-start:none;border-start-end-radius:var(--rounded-corner)}#sidebar-meta .while-writing .card-title .title{color:var(--fg-muted-2);font-size:var(--font-size-small);line-height:0}#sidebar-meta .weather{display:flex;justify-content:space-between;align-items:center;gap:1rem;border-radius:1.375rem;padding:.25rem;padding-inline-end:1.25rem;line-height:1}#sidebar-meta .weather.cloudy{animation:perlin 12s linear infinite;background-image:url("/weather/perlin.png")}@media(prefers-color-scheme: dark){#sidebar-meta .weather.cloudy{background-image:url("/weather/perlin-dark.png")}}#sidebar-meta .weather.showers{animation:rain 3s linear infinite;background-image:url("/weather/rain.svg")}@media(prefers-color-scheme: dark){#sidebar-meta .weather.showers{background-image:url("/weather/rain-dark.svg")}}#sidebar-meta .weather.snowing{animation:snow 12s linear infinite;background-image:url("/weather/snow.svg")}@media(prefers-color-scheme: dark){#sidebar-meta .weather.snowing{background-image:url("/weather/snow-dark.svg")}}#sidebar-meta .weather div{display:flex;align-items:center;gap:.5rem}#sidebar-meta .weather .title{color:inherit;font-size:var(--font-size-medium)}#sidebar-meta .weather .icon{box-shadow:var(--edge-highlight);border-radius:50%;background-color:var(--bg-muted-1);padding:.5rem;font-size:1.25rem}#sidebar-meta .weather .condition{color:var(--fg-muted-2);font-size:var(--font-size-small)}@keyframes rain{to{background-position:256px 320px}}@keyframes perlin{to{background-position:-256px -64px}}@keyframes snow{to{background-position:256px 512px}}#sidebar-meta .music{display:flex;position:relative;flex-direction:column;gap:.5rem;border-radius:1rem}#sidebar-meta .music::after{position:absolute;z-index:1;inset:0;box-shadow:var(--edge-highlight);border-radius:inherit;max-height:5rem;pointer-events:none;content:""}#sidebar-meta .music .carousel{scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;display:flex;margin-block:0;box-shadow:var(--shadow);border-radius:inherit;background-color:var(--glass-bg-1);padding-inline-start:0;overflow-x:auto;scroll-behavior:smooth;scrollbar-width:none;list-style:none}#sidebar-meta .music .carousel li{display:flex;position:relative;flex-shrink:0;gap:1rem;margin-block:0;outline:none;padding:.5rem;width:100%;overflow:hidden;scroll-snap-align:start}#sidebar-meta .music .carousel li.has-cover{animation:cover-alternate 60s ease-in-out infinite;background-image:var(--bg-overlay),var(--blurnail);background-position:center;background-size:2000%}@keyframes cover-alternate{from{background-position:50% 0%}12.5%{background-position:100% 25%}25%{background-position:50% 50%}37.5%{background-position:100% 75%}50%{background-position:50% 100%}62.5%{background-position:0% 75%}75%{background-position:50% 50%}87.5%{background-position:0% 25%}to{background-position:50% 0%}}#sidebar-meta .music .carousel li.has-cover .music-type{color:var(--fg-muted-neutral-1)}#sidebar-meta .music .carousel li.has-cover .music-artist{color:var(--fg-muted-neutral-2)}#sidebar-meta .music .carousel li>div{display:flex;flex:1;flex-direction:column;justify-content:space-between;gap:.25rem;margin-inline-end:.5rem;margin-block:.25rem;min-width:0;min-width:0;line-height:1}#sidebar-meta .music .carousel li a{position:absolute;z-index:1;inset:0;border-radius:inherit}#sidebar-meta .music .music-type{color:var(--fg-muted-1);font-size:var(--font-size-xx-small)}#sidebar-meta .music .music-artist{color:var(--fg-muted-2);font-size:var(--font-size-small)}#sidebar-meta .music .title{color:var(--fg-color);font-size:var(--font-size-medium)}#sidebar-meta .music img{margin:0;border-radius:var(--rounded-corner-small)}#sidebar-meta .music-paginator{display:flex;justify-content:center;gap:.25rem;margin-inline:1rem;margin-block:0;padding-inline-start:0;list-style:none}#sidebar-meta .music-paginator li{display:flex;margin-block:0;padding-inline-start:0;height:.75rem}#sidebar-meta .music-paginator a{transition:width var(--transition),height var(--transition),background-color var(--transition),transform var(--transition-bezier);margin-block:auto;border-radius:.375rem;background-color:var(--fg-muted-1);width:1.5rem;height:.375rem}#sidebar-meta .music-paginator a:hover{background-color:var(--fg-muted-2)}#sidebar-meta .music-paginator a:active{transform:var(--active)}#sidebar-meta .music-paginator a.active{background-color:var(--fg-color);width:.75rem;height:.75rem}#sidebar-meta .toc{display:flex;flex-direction:column;max-height:calc(var(--content-width)/3)}@media only screen and (max-width: 720px){#sidebar-meta .toc{max-height:calc(var(--content-width)/2)}}#sidebar-meta .toc .title{margin-block-end:1rem}#sidebar-meta .toc .overshoot{flex:1;margin:-1rem;padding:1rem;overflow:auto;scrollbar-width:thin}#sidebar-meta .toc .overshoot ul{margin-block:0;padding-inline-start:1rem;color:var(--fg-muted-1);font-size:var(--font-size-small)}#sidebar-meta .toc .overshoot li{margin-block:0;padding-inline-start:0}#sidebar-meta .toc .overshoot li::marker{color:inherit}#sidebar-meta .toc .overshoot a{color:inherit;text-decoration:none}#sidebar-meta .status-page{display:none;margin-block:auto;text-align:center}#sidebar-meta .status-page .title,#sidebar-meta .status-page p{color:var(--fg-muted-2)}#sidebar-meta .status-page .icon{margin-inline:auto;color:var(--fg-muted-1);font-size:3rem}#sidebar-meta .status-page p{margin-block:0}[class^=markdown-alert]{display:flow-root;position:relative}[class^=markdown-alert]::before{margin-inline-start:1.5rem;content:var(--alert-title);color:var(--accent-color);font-weight:var(--font-weight-semi-bold)}[class^=markdown-alert]::after{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--alert-icon);position:absolute;margin-inline-end:.25rem;inset-block-start:0.0625rem;inset-inline-start:.75rem;color:var(--accent-color);font-size:1.25rem}[class^=markdown-alert]>:first-child{margin-block-start:.25rem}[class^=markdown-alert]>:last-child{margin-block-end:0}.markdown-alert-note,.markdown-alert-tip,.markdown-alert-important,.markdown-alert-warning,.markdown-alert-caution,.markdown-alert-red,.markdown-alert-orange,.markdown-alert-yellow,.markdown-alert-green,.markdown-alert-blue,.markdown-alert-purple{--accent-color: hsl( var(--accent-light-h), var(--accent-light-s), var(--accent-light-l) );--accent-h: var(--accent-light-h);--accent-s: var(--accent-light-s);--accent-l: var(--accent-light-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) ) );--accent-highlight: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--disabled-opacity) );--bg-muted-1: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-1) );--bg-muted-2: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-2) );--l: clamp(0, (l / 0.623 - 1) * -infinity, 1);--a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l))));--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));--shadow-glow: 0 0 0 0.0625rem var(--bg-muted-2), 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--shadow-glow-inset: inset 0 0 0 0.0625rem var(--bg-muted-2), inset 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), inset 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--text-shadow-glow: var(--bg-muted-2) 0 0 0.25rem, var(--accent-color) 0 0 0.75rem}@media(prefers-color-scheme: dark){.markdown-alert-note,.markdown-alert-tip,.markdown-alert-important,.markdown-alert-warning,.markdown-alert-caution,.markdown-alert-red,.markdown-alert-orange,.markdown-alert-yellow,.markdown-alert-green,.markdown-alert-blue,.markdown-alert-purple{--accent-color: hsl( var(--accent-dark-h), var(--accent-dark-s), var(--accent-dark-l) );--accent-h: var(--accent-dark-h);--accent-s: var(--accent-dark-s);--accent-l: var(--accent-dark-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) - var(--accent-l) * 0.9) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) - var(--accent-l) * 0.8), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.9) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.95) ) )}}.markdown-alert-note::before{content:var(--alert-note-title)}.markdown-alert-note::after{--icon: var(--icon-info)}.markdown-alert-tip::before{content:var(--alert-tip-title)}.markdown-alert-tip::after{--icon: var(--icon-lightbulb)}.markdown-alert-important::before{content:var(--alert-important-title)}.markdown-alert-important::after{--icon: var(--icon-warning-diamond)}.markdown-alert-warning::before{content:var(--alert-warning-title)}.markdown-alert-warning::after{--icon: var(--icon-warning)}.markdown-alert-caution::before{content:var(--alert-caution-title)}.markdown-alert-caution::after{--icon: var(--icon-warning-octagon)}.markdown-alert-caution,.markdown-alert-red{--accent-h: var(--red-h);--accent-s: var(--red-s);--accent-l: var(--red-l);--accent-color: var(--red-fg)}.markdown-alert-orange{--accent-h: var(--orange-h);--accent-s: var(--orange-s);--accent-l: var(--orange-l);--accent-color: var(--orange-fg)}.markdown-alert-warning,.markdown-alert-yellow{--accent-h: var(--yellow-h);--accent-s: var(--yellow-s);--accent-l: var(--yellow-l);--accent-color: var(--yellow-fg)}.markdown-alert-tip,.markdown-alert-green{--accent-h: var(--green-h);--accent-s: var(--green-s);--accent-l: var(--green-l);--accent-color: var(--green-fg)}.markdown-alert-note,.markdown-alert-blue{--accent-h: var(--blue-h);--accent-s: var(--blue-s);--accent-l: var(--blue-l);--accent-color: var(--blue-fg)}.markdown-alert-important,.markdown-alert-purple{--accent-h: var(--purple-h);--accent-s: var(--purple-s);--accent-l: var(--purple-l);--accent-color: var(--purple-fg)}.article-details{display:flex;flex-direction:column;gap:.75rem;margin-block-end:2rem;color:var(--fg-muted-2);font-size:var(--font-size-small)}.article-details .items{display:flex;flex-wrap:wrap;align-items:flex-start;gap:.25rem .5rem}.article-details .item{display:inline-flex;align-items:center;gap:.375rem;border-radius:var(--rounded-corner-small)}.article-details a{color:inherit;font-weight:var(--font-weight-normal);font-size:var(--font-size-small);text-decoration-color:rgba(0,0,0,0)}.article-details a:hover{text-decoration-style:solid;text-decoration-color:var(--fg-muted-1)}.article-details .tooltip{cursor:help}.article-details .icon{box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding:.375rem;color:var(--accent-color);font-size:1.25rem}.article-details .tags{display:flex;flex-wrap:wrap;gap:0 .5rem;margin:0;border-block-start:0.0625rem solid var(--bg-muted-2);padding-inline-start:0;padding-block-start:.5rem;width:100%;list-style:none}.article-details .tags li{display:flex;margin-block:0;padding:0}.article-details .tags a::before{content:"#"}.article-list{display:grid;grid-template-columns:repeat(auto-fill, minmax(calc(var(--content-width) / 2.5), 1fr));gap:1rem;margin-block-start:2rem}.article-list.categories article{position:relative}.article-list.categories article:hover .icon:last-child{transform:translateY(-50%) rotate(-10deg) scale(1.5)}.article-list.categories article .icon:last-child{position:absolute;transform:translateY(-50%);z-index:-1;transition:transform var(--transition-bezier);inset-block-start:50%;inset-inline-end:-3rem;color:var(--bg-muted-1);font-size:12rem}.article-list article{display:flex;position:relative;flex-direction:column;justify-content:space-between;box-shadow:var(--edge-highlight),var(--shadow);border-radius:1.25rem;background-color:var(--bg-muted-1);padding:0;overflow:hidden}.article-list article.draft{opacity:var(--disabled-opacity);filter:grayscale(1);transition-duration:var(--transition);transition-property:opacity,filter}.article-list article.draft:hover{opacity:1;filter:none}.article-list article.has-banner.has-accent-color{background-image:var(--bg-overlay),var(--blurnail);background-position:center;background-size:cover}.article-list article.has-accent-color{--accent-color: hsl( var(--accent-light-h), var(--accent-light-s), var(--accent-light-l) );--accent-h: var(--accent-light-h);--accent-s: var(--accent-light-s);--accent-l: var(--accent-light-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) - var(--accent-l) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.8), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.9) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.75) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.65) ) );--accent-highlight: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--disabled-opacity) );--bg-muted-1: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-1) );--bg-muted-2: hsla( var(--accent-h), var(--accent-s), var(--accent-l), var(--color-opacity-2) );--l: clamp(0, (l / 0.623 - 1) * -infinity, 1);--a: calc(var(--l) + (var(--dim-opacity) * (1 - var(--l))));--contrast-color: oklch(from var(--accent-color) var(--l) 0 h / var(--a));--shadow-glow: 0 0 0 0.0625rem var(--bg-muted-2), 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--shadow-glow-inset: inset 0 0 0 0.0625rem var(--bg-muted-2), inset 0 0.125rem 0.375rem 0.125rem var(--bg-muted-1), inset 0 0.25rem 1.5rem 0.25rem var(--bg-muted-2);--text-shadow-glow: var(--bg-muted-2) 0 0 0.25rem, var(--accent-color) 0 0 0.75rem}@media(prefers-color-scheme: dark){.article-list article.has-accent-color{--accent-color: hsl( var(--accent-dark-h), var(--accent-dark-s), var(--accent-dark-l) );--accent-h: var(--accent-dark-h);--accent-s: var(--accent-dark-s);--accent-l: var(--accent-dark-l);--bg-color: hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.7), calc(var(--accent-l) - var(--accent-l) * 0.9) );--fg-muted-1: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-1-opacity) );--fg-muted-2: hsla( var(--accent-h), var(--accent-s), calc(var(--accent-l) + (100% - var(--accent-l)) * 0.85), var(--fg-muted-2-opacity) );--glass-bg-1: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.6), calc(var(--accent-l) - var(--accent-l) * 0.8), var(--dim-opacity) );--glass-bg-2: hsla( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7), var(--dim-opacity) );--crt-bg: radial-gradient( hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.7) ), hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.9) ) 80%, hsl( var(--accent-h), calc(var(--accent-s) - var(--accent-s) * 0.5), calc(var(--accent-l) - var(--accent-l) * 0.95) ) )}}.article-list article>div{display:flex;flex:1;flex-direction:column;margin:1rem}.article-list article>div>a{text-decoration:none}.article-list article h3{margin:0;border-inline-start:.25rem solid;border-start-start-radius:.25rem;border-end-start-radius:.25rem;padding-inline-start:.5rem;color:var(--accent-color);font-weight:var(--font-weight-semi-bold);text-decoration:none}.article-list article h3:hover::after{opacity:1}.article-list article h3::after{opacity:0;transition:opacity var(--transition);content:" →"}.article-list article .description p{margin-block-end:.75rem}.article-list article .open-post{--mask-image: linear-gradient( to bottom, hsl(0, 0%, 0%) 0%, hsla(0, 0%, 0%, 0.987) 8.1%, hsla(0, 0%, 0%, 0.951) 15.5%, hsla(0, 0%, 0%, 0.896) 22.5%, hsla(0, 0%, 0%, 0.825) 29%, hsla(0, 0%, 0%, 0.741) 35.3%, hsla(0, 0%, 0%, 0.648) 41.2%, hsla(0, 0%, 0%, 0.55) 47.1%, hsla(0, 0%, 0%, 0.45) 52.9%, hsla(0, 0%, 0%, 0.352) 58.8%, hsla(0, 0%, 0%, 0.259) 64.7%, hsla(0, 0%, 0%, 0.175) 71%, hsla(0, 0%, 0%, 0.104) 77.5%, hsla(0, 0%, 0%, 0.049) 84.5%, hsla(0, 0%, 0%, 0.013) 91.9%, hsla(0, 0%, 0%, 0) 100% );mask-image:var(--mask-image);-webkit-mask-image:var(--mask-image);display:flex;position:relative;justify-content:center;align-items:center;margin-block-end:-2rem;box-shadow:var(--edge-highlight);border-radius:0;aspect-ratio:2/1;overflow:hidden;text-decoration:none}.article-list article .open-post:hover::before{background-color:var(--shade-color)}.article-list article .open-post:hover img{transform:var(--hover);filter:blur(0.25rem)}.article-list article .open-post:hover .icon{transform:none;opacity:1}.article-list article .open-post:active img{transform:none}.article-list article .open-post::before{position:absolute;z-index:1;transition:background-color var(--transition);inset:0;border-radius:inherit;content:""}.article-list article .open-post img{position:absolute;transition-duration:var(--transition);transition-property:transform,filter;margin:0;inset:0;width:100%;height:100%;object-fit:cover}.article-list article .open-post .icon{transform:scale(1.5);opacity:0;z-index:1;transition:transform var(--transition-bezier),opacity var(--transition);color:#fff;font-size:1.25rem}.article-list article .article-details{flex:1;justify-content:flex-end;margin-block-end:0}.article-list article .article-details .tags{margin-block-start:auto;margin-block-end:-0.25rem}.paginator{display:flex;justify-content:center;align-items:center;gap:.25rem;margin-block-start:4rem}.paginator:has(a.paginator-item.previous:active) .paginator-counter{border-start-start-radius:1rem;border-end-start-radius:1rem}.paginator:has(a.paginator-item.next:active) .paginator-counter{border-start-end-radius:1rem;border-end-end-radius:1rem}.paginator-item{display:inline-block;transition:border-radius var(--transition),border-start-end-radius var(--transition),border-end-end-radius var(--transition),border-start-start-radius var(--transition),border-end-start-radius var(--transition),background-color var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:1rem;background-color:var(--bg-muted-1);padding:.5rem;color:var(--accent-color);line-height:0;text-decoration:none}.paginator-item:not([aria-disabled=true]):hover{background-color:var(--bg-muted-2)}.paginator-item:not([aria-disabled=true]):active{transform:var(--active);border-radius:1rem}.paginator-item-previous{border-start-end-radius:var(--rounded-corner-small);border-end-end-radius:var(--rounded-corner-small)}.paginator-item-next{border-start-start-radius:var(--rounded-corner-small);border-end-start-radius:var(--rounded-corner-small)}.paginator-counter{box-shadow:var(--edge-highlight),var(--shadow-glow);background-image:linear-gradient(to bottom right, var(--accent-color), var(--accent-highlight));color:var(--contrast-color);text-shadow:var(--text-shadow-glow);display:inline-block;transition:var(--transition-bezier);transition-property:border-start-start-radius,border-end-start-radius,border-start-end-radius,border-end-end-radius;border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding:.25rem .5rem;font-weight:var(--font-weight-semi-bold);font-variant-numeric:tabular-nums}.article-nav{display:flex;gap:.25rem}@media only screen and (max-width: 720px){.article-nav{flex-direction:column}}.article-nav-item{flex:1;transition:box-shadow var(--transition),background-color var(--transition),transform var(--transition-bezier);border-radius:var(--rounded-corner);padding:.75rem 1rem;min-width:0;text-decoration:none}.article-nav-item:hover{box-shadow:var(--edge-highlight),var(--shadow);background-color:var(--bg-muted-1)}.article-nav-item:hover .article-title{color:var(--accent-color)}.article-nav-item:active{transform:var(--active)}.article-nav-item.prev{text-align:start}.article-nav-item.prev .nav-arrow::before{content:"← "}.article-nav-item.next{text-align:end}.article-nav-item.next .nav-arrow::after{content:" →"}.article-nav .nav-arrow{margin-block-end:.5rem;color:var(--fg-muted-2);font-weight:var(--font-weight-normal);font-size:var(--font-size-small)}.article-nav .article-title{display:block;transition:color var(--transition);max-width:100%;overflow:hidden;color:var(--fg-muted-1);text-overflow:ellipsis;white-space:nowrap}.audio{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none;padding-block:0;color:var(--accent-color);font-size:var(--font-size-medium)}.audio:hover{color:var(--accent-color) !important}.audio:hover .icon{animation:speaker var(--transition-long) ease-out infinite}@keyframes speaker{from{--icon: var(--icon-speaker-none)}50%{--icon: var(--icon-speaker-low)}to{--icon: var(--icon-speaker-high)}}.audio:active{transform:var(--active)}.audio .icon{vertical-align:-0.1875rem;margin-inline-start:.25rem}body.has-banner{background-image:var(--bg-overlay),var(--blurnail);background-position:center;background-size:cover;background-attachment:fixed}#banner{--mask: linear-gradient(black calc(100% - 2rem), transparent);-webkit-mask-image:var(--mask);display:flex;grid-area:banner;justify-content:center;mask-image:var(--mask);margin-block-end:-2.5rem;padding-block:3.25rem 2.5rem;width:100%}#banner div{position:relative;margin-inline:1rem;aspect-ratio:2/1;width:100%;max-width:var(--content-width)}#banner img{position:absolute;margin:0;inset:0;border-radius:var(--rounded-corner) var(--rounded-corner) 0 0 !important;aspect-ratio:2/1;width:100%;object-fit:cover}#banner img:nth-of-type(1){opacity:var(--dim-opacity);z-index:-1;filter:blur(1.5rem);animation:banner-glow-load calc(.525s + var(--transition-long));inset-block-start:1rem}@keyframes banner-glow-load{from,52.5%{opacity:0}}#banner img:nth-of-type(2){transform-origin:bottom;animation:banner-load calc(.525s + var(--transition-longer))}@keyframes banner-load{from,52.5%{transform:translateY(2rem) scale(0.95);opacity:0}}#banner+#main-content{margin-block-start:0}#banner+#main-content article>h1:first-of-type,#banner+#main-content h1:first-of-type{margin-inline:-1rem;margin-block:0;border-radius:var(--rounded-corner) var(--rounded-corner) 0 0;padding:1rem;width:unset;-webkit-backdrop-filter:blur(1rem);backdrop-filter:blur(1rem);animation:banner-heading calc(.525s + var(--transition-longer));box-shadow:inset 0 0.0625rem 0 var(--accent-highlight);background-image:linear-gradient(to bottom, var(--bg-muted-1), transparent 50%);background-size:auto 200%;text-decoration:none}@keyframes banner-heading{from,52.5%{box-shadow:none;background-position-y:100%}}#comments #qrcode{float:inline-end;cursor:help;box-sizing:content-box;margin-inline-start:1rem;margin-block-start:3rem;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:#fff;padding:.75rem;width:7.25rem;height:7.25rem}#comments #qrcode svg{pointer-events:none}@media only screen and (max-width: 720px){#comments #qrcode{display:none}}#comments #comments-wrapper{display:flex;flex-direction:column;gap:2rem;margin-block-start:2rem}#comments #comments-wrapper #comments-status{color:var(--fg-muted-1);font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-x-large);text-align:center}#comments #comments-wrapper .comment{display:grid;grid-template-columns:auto 1fr;grid-template-areas:"avatar header " "...... post " "...... media " "...... card " "...... interactions";column-gap:1rem;justify-items:start;animation:slide-in-right var(--transition-bezier)}:root[dir*=rtl] #comments #comments-wrapper .comment{animation:slide-in-left var(--transition-bezier)}@media only screen and (max-width: 720px){#comments #comments-wrapper .comment{grid-template-areas:"avatar header " "post post " "media media " "card card " "interactions interactions";align-items:start}}#comments #comments-wrapper .comment.comment-reply{position:relative;border-inline-start:.25rem solid var(--bg-muted-2);border-start-start-radius:.25rem;border-end-start-radius:.25rem;padding-inline-start:1rem}#comments #comments-wrapper .comment.comment-reply:has(+.comment-reply){border-end-start-radius:0}#comments #comments-wrapper .comment.comment-reply+.comment-reply{margin-block-start:-2rem;border-start-start-radius:0;padding-block-start:2rem}#comments #comments-wrapper .comment .avatar-link{grid-area:avatar;width:4rem;height:4rem}@media only screen and (max-width: 480px){#comments #comments-wrapper .comment .avatar-link{width:3rem;height:3rem}}#comments #comments-wrapper .comment .avatar-link .avatar{transition:transform var(--transition-bezier);margin:0;background-size:cover;width:100%;height:100%}#comments #comments-wrapper .comment .avatar-link .avatar:hover{transform:rotate(10deg) var(--hover);border-radius:var(--rounded-corner)}#comments #comments-wrapper .comment .avatar-link .avatar:active{transform:var(--active)}#comments #comments-wrapper .comment .mention{transition:all var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner-small);background-color:var(--bg-muted-1);padding-inline:.375rem;text-decoration:none;display:inline-block}#comments #comments-wrapper .comment .mention:hover{background-color:var(--bg-muted-2)}#comments #comments-wrapper .comment .mention:active{transform:var(--active)}#comments #comments-wrapper .comment .invisible{display:none}#comments #comments-wrapper .comment .ellipsis::after{content:"…"}#comments #comments-wrapper .comment header{display:flex;grid-area:header;flex-direction:column;width:100%;min-width:0;color:var(--fg-muted-1);line-height:1}#comments #comments-wrapper .comment header .display,#comments #comments-wrapper .comment header .instance{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}#comments #comments-wrapper .comment header .display{color:var(--fg-color);line-height:1.5rem;text-decoration:none}#comments #comments-wrapper .comment header .display .emoji{cursor:unset}#comments #comments-wrapper .comment header .display .emoji:hover{transform:none}#comments #comments-wrapper .comment header .instance{margin-block-start:.25rem;font-weight:var(--font-weight-semi-bold);font-size:var(--font-size-x-small)}#comments #comments-wrapper .comment header .instance.op{cursor:help}#comments #comments-wrapper .comment header .instance.op::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-seal-check);display:inline-block;vertical-align:text-top;margin-inline-end:.25rem;font-size:1rem}#comments #comments-wrapper .comment header .timestamp{margin-block-start:.25rem}#comments #comments-wrapper .comment header .timestamp a{color:inherit;font-size:var(--font-size-x-small);text-decoration-color:rgba(0,0,0,0)}#comments #comments-wrapper .comment header .timestamp a:hover{text-decoration-style:solid;text-decoration-color:inherit}#comments #comments-wrapper .comment header .timestamp a::after{vertical-align:top}#comments #comments-wrapper .comment details{border-radius:var(--rounded-corner-small);background-image:linear-gradient(to right, transparent, transparent 0.5rem, var(--bg-muted-1) 0.5rem, var(--bg-muted-1) calc(100% - 0.5rem), transparent calc(100% - 0.5rem), transparent),linear-gradient(to right, transparent, transparent 0.5rem, var(--bg-color) 0.5rem, var(--bg-color) calc(100% - 0.5rem), transparent calc(100% - 0.5rem), transparent),repeating-linear-gradient(45deg, var(--contrast-color), var(--contrast-color) 0.25rem, var(--accent-color) 0.25rem, var(--accent-color) 0.5rem);width:100%}#comments #comments-wrapper .comment details summary{border-radius:0;background-image:none}#comments #comments-wrapper .comment main{grid-area:post;justify-self:stretch;margin-block-start:1rem;padding:0}#comments #comments-wrapper .comment main :first-child{margin-block-start:0}#comments #comments-wrapper .comment main :last-child{margin-block-end:0}#comments #comments-wrapper .comment .attachments{display:grid;grid-template-columns:repeat(auto-fit, minmax(16rem, 1fr));grid-area:media;gap:.5rem;margin-block-start:1rem}#comments #comments-wrapper .comment .attachments img,#comments #comments-wrapper .comment .attachments video{margin:0}#comments #comments-wrapper .comment .meta-card{grid-area:card;transition:transform var(--transition-bezier);margin-block-start:1rem;width:min(var(--content-width)/2,100%);font-weight:var(--font-weight-normal);text-decoration:none}#comments #comments-wrapper .comment .meta-card:hover img{transform:var(--hover);box-shadow:var(--edge-highlight),var(--shadow-raised);border-radius:var(--rounded-corner-small)}#comments #comments-wrapper .comment .meta-card:hover figcaption{border-radius:var(--rounded-corner);background-color:var(--bg-muted-2)}#comments #comments-wrapper .comment .meta-card:active{transform:var(--active)}#comments #comments-wrapper .comment .meta-card figure{display:flex;flex-direction:column;gap:.25rem;margin:0}#comments #comments-wrapper .comment .meta-card figure img{margin:0;border-radius:var(--rounded-corner) var(--rounded-corner) var(--rounded-corner-small) var(--rounded-corner-small);aspect-ratio:16/9;object-fit:cover}#comments #comments-wrapper .comment .meta-card figure img+figcaption{border-radius:var(--rounded-corner-small) var(--rounded-corner-small) var(--rounded-corner) var(--rounded-corner)}#comments #comments-wrapper .comment .meta-card figure figcaption{display:flex;flex-direction:column;gap:.25rem;transition-duration:var(--transition);transition-property:border-radius,background-color;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:1rem;color:var(--fg-color);font-size:var(--font-size-medium);text-align:start}#comments #comments-wrapper .comment .meta-card figure figcaption p{margin:0;color:var(--fg-muted-2);font-size:var(--font-size-small)}#comments #comments-wrapper .comment footer{display:flex;grid-area:interactions;align-items:center;gap:.25rem;margin-block-start:1rem;width:100%;min-width:0}@media only screen and (max-width: 480px){#comments #comments-wrapper .comment footer{flex-wrap:wrap}}#comments #comments-wrapper .comment footer .boosts,#comments #comments-wrapper .comment footer .faves,#comments #comments-wrapper .comment footer .reaction{padding:.375rem .75rem;font-variant-numeric:tabular-nums;white-space:nowrap}#comments #comments-wrapper .comment footer .boosts,#comments #comments-wrapper .comment footer .faves{transition:box-shadow var(--transition),border-radius var(--transition),transform var(--transition-bezier);border-radius:1.125rem;text-decoration:none}#comments #comments-wrapper .comment footer .boosts .icon,#comments #comments-wrapper .comment footer .faves .icon{vertical-align:-0.125rem;transition:transform var(--transition-bezier);margin-inline-end:.25rem}#comments #comments-wrapper .comment footer .boosts:hover,#comments #comments-wrapper .comment footer .faves:hover{box-shadow:var(--edge-highlight);text-decoration:none}#comments #comments-wrapper .comment footer .boosts:active,#comments #comments-wrapper .comment footer .faves:active{transform:var(--active);border-radius:1.125rem}#comments #comments-wrapper .comment footer .boosts{border-start-end-radius:var(--rounded-corner-small);border-end-end-radius:var(--rounded-corner-small);padding-inline-end:.625rem;color:var(--purple-fg)}#comments #comments-wrapper .comment footer .boosts .icon{--icon: var(--icon-repeat)}#comments #comments-wrapper .comment footer .boosts:hover{background-color:var(--purple-bg-1)}#comments #comments-wrapper .comment footer .boosts:hover .icon{transform:rotate(180deg)}#comments #comments-wrapper .comment footer .faves{border-start-start-radius:var(--rounded-corner-small);border-end-start-radius:var(--rounded-corner-small);padding-inline-start:.625rem;color:var(--yellow-fg)}#comments #comments-wrapper .comment footer .faves .icon{--icon: var(--icon-star)}#comments #comments-wrapper .comment footer .faves:hover{background-color:var(--yellow-bg-1)}#comments #comments-wrapper .comment footer .faves:hover .icon{transform:rotate(72deg)}#comments #comments-wrapper .comment footer .reactions{display:flex;gap:.25rem;margin:-0.5rem -1rem;padding:.5rem 1rem;min-width:0;overflow-x:auto;scrollbar-width:none;color:var(--accent-color);font-weight:var(--font-weight-semi-bold)}#comments #comments-wrapper .comment footer .reactions .reaction{display:flex;flex-shrink:0;align-items:center;gap:.25rem;cursor:zoom-in;border:0.0625rem solid var(--bg-muted-2);border-radius:1.1875rem}#comments #comments-wrapper .comment footer .reactions .reaction:hover .emoji{transform:scale(2)}#comments #comments-wrapper .comment footer .reactions .reaction .emoji{cursor:unset}#comments #comments-wrapper .comment footer .reactions .reaction span{font-variant-numeric:tabular-nums}.emoji{display:inline-block;vertical-align:bottom;transition:transform var(--transition-bezier);cursor:zoom-in;margin:0;box-shadow:none;border-radius:0;background-color:rgba(0,0,0,0);width:1.5rem;height:1.5rem}.emoji:hover{transform:scale(2)}.emoji.big{width:3rem;height:3rem}.footnotes-list p{margin-block:0}.footnote-reference{display:inline-flex;vertical-align:baseline;transition:background-color var(--transition),outline-offset calc(var(--transition-long)*3),transform var(--transition-bezier);margin-inline-start:.25rem;outline-offset:calc(var(--content-width)/2) !important;border-radius:.6875em;background-color:var(--fg-muted-1);line-height:1.375em}.footnote-reference:hover{background-color:var(--accent-color)}.footnote-reference:hover a::after{mask-position:center 1.125rem}.footnote-reference:active{transform:var(--active)}.footnote-reference:target{animation:none !important;outline-offset:-0.1875rem !important;background-color:var(--accent-color)}.footnote-reference a{flex:1;border-radius:inherit;padding-inline:0.5625rem 0.375rem;color:var(--bg-color);font-size:var(--font-size-small);font-variant-numeric:tabular-nums;text-align:center;text-decoration:none}.footnote-reference a::after{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:"";--icon: var(--icon-caret-down);display:inline-block;vertical-align:-0.4375rem;mask-position:center;mask-size:1em;transition:mask-position var(--transition);margin-inline-start:.25rem;height:1.375rem}.giallo:has(.giallo-ln){padding-inline:0}.giallo-l{display:inline-block;width:100%}.giallo-ln{display:inline-block;margin-right:1ch;min-width:3ch;user-select:none;text-align:right}.icon{font-style:normal;font-variant:normal;font-weight:normal;line-height:1;user-select:none;text-transform:none;-webkit-user-select:none;display:inline-block;box-sizing:content-box}.icon::before{-webkit-mask-image:var(--icon);-webkit-mask-size:cover;display:block;mask-image:var(--icon);mask-size:cover;background-color:currentColor;width:1em;height:1em;content:""}iframe.vimeo-embed,iframe.youtube-embed{aspect-ratio:16/9}.marquee{margin-inline:-1rem;padding-inline:1rem;overflow:hidden}.marquee.overshoot-row>*{animation:marquee-scroll calc((var(--text-width) + var(--container-width))/40*1s) linear infinite}@keyframes marquee-scroll{0%,10%{transform:translateX(0)}45%,55%{transform:translateX(calc((var(--container-width) - var(--text-width)) * 1px))}90%,100%{transform:translateX(0)}}.marquee>*{display:inline-block;white-space:nowrap}picture#not-found{display:block}picture#not-found img{width:8rem}img#not-found{width:8rem}.pre-container{position:relative;margin:1rem 0 1rem;box-shadow:var(--shadow);border-radius:var(--rounded-corner)}.pre-container::after{position:absolute;inset:0;box-shadow:var(--edge-highlight);border-radius:inherit;pointer-events:none;content:""}.pre-container .header{display:flex;justify-content:space-between;align-items:center;transition:border-block-end-color var(--transition);border-radius:var(--rounded-corner) var(--rounded-corner) 0 0;border-block-end:0.0625rem solid rgba(0,0,0,0);background-image:linear-gradient(to right, transparent 50%, var(--bg-muted-neutral-1) 75%, transparent 100%);background-size:200%;padding:.25rem}.pre-container .header:not(.z-l-code),.pre-container .header:not(.z-d-code){background-color:var(--bg-muted-1);color:var(--accent-color)}.pre-container .header:not(.z-l-code) button:hover,.pre-container .header:not(.z-d-code) button:hover{background-color:var(--bg-muted-1)}.pre-container .header:not(.z-l-code).active,.pre-container .header:not(.z-d-code).active{border-block-end-color:var(--bg-muted-1)}.pre-container .header:not(.z-l-code).active button,.pre-container .header:not(.z-d-code).active button{background-color:var(--bg-muted-1)}.pre-container .header span{margin-inline-start:.75rem;font-weight:var(--font-weight-semi-bold)}.pre-container .header button{position:relative;transition:box-shadow var(--transition),background-color var(--transition),transform var(--transition-bezier);cursor:pointer;box-shadow:none;border-radius:var(--rounded-corner-small);background-color:rgba(0,0,0,0);padding:.5rem;color:inherit;line-height:0}.pre-container .header button:hover{box-shadow:var(--edge-highlight);background-color:var(--bg-muted-neutral-1)}.pre-container .header button:active{transform:var(--active)}.pre-container .header button:disabled{cursor:not-allowed}.pre-container .header button:disabled:active{transform:none}.pre-container .header button .icon{transition:opacity var(--transition-longer),transform var(--transition-bezier-longer);font-size:1rem}.pre-container .header button .icon.copy{--icon: var(--icon-copy);transform-origin:bottom left}.pre-container .header button .icon.check{--icon: var(--icon-check-square-offset);position:absolute;transform:scale(0.5);transform-origin:top right;opacity:0;inset:.5rem}.pre-container .header.active{animation:active-shimmer var(--transition-long);border-block-end-color:var(--bg-muted-neutral-1)}.pre-container .header.active button{opacity:1}.pre-container .header.active button .icon.copy{transform:scale(0.5);opacity:0}.pre-container .header.active button .icon.check{transform:none;opacity:1}@keyframes active-shimmer{to{background-position-x:-200%}}.pre-container pre{margin:0;box-shadow:none;border-radius:0 0 var(--rounded-corner) var(--rounded-corner)}#search-modal{display:flex;position:fixed;flex-direction:column;align-items:center;visibility:hidden;opacity:0;z-index:9999;transition:visibility var(--transition),opacity var(--transition);inset:0;background-color:var(--shade-color);padding-block-start:15vh;width:100%;height:100vh}#search-modal.active{visibility:visible;opacity:1}#search-modal.active #search-modal-content{transform:none}#search-modal-content{display:flex;flex-direction:column;gap:1rem;transform:var(--hover);backdrop-filter:var(--blur);-webkit-backdrop-filter:var(--blur);transition:border-radius var(--transition),padding var(--transition),max-width var(--transition),transform var(--transition-bezier-longer);margin:-0.5rem;box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:2.25rem 2.25rem 1.75rem 1.75rem;background-color:var(--glass-bg-1);padding:1rem;width:calc(100% - 2rem);max-width:calc(var(--content-width)/1.5);overflow:hidden}#search-modal-content:has(#search-results:empty){border-radius:1.75rem;padding:.5rem;max-width:calc(var(--content-width)/2)}#search-modal-content>div{position:relative}#search-modal #search-input{flex-shrink:0;box-shadow:var(--edge-highlight);border-radius:9999px;padding:0 1rem;width:100%;height:2.5rem}#search-modal #search-input:placeholder-shown+div .icon{transform:none;opacity:var(--disabled-opacity);pointer-events:all}#search-modal #search-input+div .icon{position:absolute;transform:translateX(0.5rem);opacity:0;transition:opacity var(--transition),transform var(--transition-bezier);cursor:help;inset-block-start:.625rem;inset-inline-end:1rem;pointer-events:none;color:var(--fg-muted-2);font-size:1.25rem}#search-modal #search-results{display:none;flex:1;flex-direction:column;gap:.5rem;margin:-1rem;padding:1rem;max-height:50vh;overflow:auto;scrollbar-width:thin}#search-modal #search-results .item{display:inline-flex;flex-direction:column;box-shadow:var(--edge-highlight);border-radius:var(--rounded-corner);background-color:var(--bg-muted-1);padding:.75rem 1rem}#search-modal #search-results .item a{text-decoration:none}#search-modal #search-results .item a::after{content:" →"}#search-modal #search-results .item span{margin-block-start:.25rem;border-block-start:rpx(1) solid var(--bg-muted-2);padding-block-start:.25rem;color:var(--fg-muted-2)}#search-modal #search-results .item span strong{color:var(--fg-color)}#skip-to-content{position:absolute;transform:translateX(-50%) translateY(calc(-100% - 1rem));z-index:9999;transition:backdrop-filter var(--transition),box-shadow var(--transition),transform var(--transition-bezier);inset-inline-start:50vw;border-radius:9999px;background-color:var(--glass-bg-1);padding:.375rem .75rem;width:max-content;text-decoration:none}#skip-to-content:focus-visible,#skip-to-content:focus{-webkit-backdrop-filter:var(--blur);transform:translateX(-50%) translateY(1rem);backdrop-filter:var(--blur);box-shadow:var(--edge-highlight),var(--shadow)}.statement-container{margin:1rem 0;box-shadow:var(--edge-highlight),var(--shadow);border-radius:var(--rounded-corner);padding:1rem}.statement-container :last-child{margin-block-end:0}.statement-container>:nth-child(2){margin-block-start:.5rem}.statement-container .title{font-weight:var(--font-weight-semi-bold)}.statement-container .title .icon{vertical-align:-0.125rem;margin-inline-end:.25rem}.statement-container.archive{--accent-color: var(--purple-fg);--accent-highlight: hsla( var(--purple-h), var(--purple-s), var(--purple-l), var(--disabled-opacity) );background-color:var(--purple-bg-1);color:var(--purple-fg)}.statement-container.disclaimer{--accent-color: var(--red-fg);--accent-highlight: hsla( var(--red-h), var(--red-s), var(--red-l), var(--disabled-opacity) );background-color:var(--red-bg-1);color:var(--red-fg)}.statement-container.trigger{--accent-color: var(--yellow-fg);--accent-highlight: hsla( var(--yellow-h), var(--yellow-s), var(--yellow-l), var(--disabled-opacity) );background-color:var(--yellow-bg-1);color:var(--yellow-fg)}#tag-chips{display:flex;flex-wrap:wrap;gap:.25rem;margin:0;padding-inline-start:0;list-style:none}#tag-chips li{display:flex;margin-block:0;padding-inline-start:0}#tag-chips a{display:flex;transition:background-color var(--transition),color var(--transition),transform var(--transition-bezier);box-shadow:var(--edge-highlight);border-radius:1rem;background-color:var(--bg-muted-1);padding:.25rem;padding-inline-start:.75rem;color:var(--fg-muted-1);font-size:var(--font-size-small);line-height:1.5rem;text-decoration:none}#tag-chips a:hover{background-color:var(--bg-muted-2);color:var(--fg-muted-2)}#tag-chips a:active{transform:var(--active)}#tag-chips a::before{content:"#"}#tag-chips a .counter{transition:inherit;margin-inline-start:.375rem;box-shadow:var(--edge-highlight);border-radius:inherit;background-color:inherit;aspect-ratio:1/1;height:1.5rem;line-height:1.5rem;font-variant-numeric:tabular-nums;text-align:center}h1:hover .zola-anchor,h2:hover .zola-anchor,h3:hover .zola-anchor,h4:hover .zola-anchor,h5:hover .zola-anchor,h6:hover .zola-anchor{transform:none;opacity:1}h1:has(.zola-anchor),h2:has(.zola-anchor),h3:has(.zola-anchor),h4:has(.zola-anchor),h5:has(.zola-anchor),h6:has(.zola-anchor){position:relative}.zola-anchor{position:absolute;transform:translateX(0.25em);opacity:0;transition:color var(--transition),opacity var(--transition),transform var(--transition-bezier);inset-inline-start:-1.25em;padding-inline-end:.25em;color:var(--fg-muted-1);line-height:1}@media only screen and (max-width: 720px){.zola-anchor{display:none}}.zola-anchor:hover{color:var(--fg-muted-2)}.zola-anchor:active{transform:var(--active)}.zola-anchor:focus-visible{opacity:1}:target .zola-anchor{transform:none;opacity:var(--disabled-opacity)}body>div:last-child:has(div:last-child[style]:not([class]):not([id])){z-index:9999 !important;background-color:var(--shade-color) !important}body>div:last-child>div:last-child[style]:not([class]):not([id]){box-shadow:var(--edge-highlight),var(--shadow-glass);border-radius:calc(var(--rounded-corner) + 1rem) !important;background-color:hsla(var(--red-h), calc(var(--red-s) - var(--red-s) * 0.7), calc(var(--red-l) + (100% - var(--red-l)) * 0.9), var(--dim-opacity)) !important;padding:1rem !important;width:fit-content;max-width:calc(100% - 2rem);-webkit-backdrop-filter:var(--blur);backdrop-filter:var(--blur);filter:none !important}@media(prefers-color-scheme: dark){body>div:last-child>div:last-child[style]:not([class]):not([id]){background-color:hsla(var(--red-h), calc(var(--red-s) - var(--red-s) * 0.6), calc(var(--red-l) - var(--red-l) * 0.8), var(--dim-opacity)) !important}}body>div:last-child>div:last-child[style]:not([class]):not([id])>p[style]:first-child{margin-block-end:1rem !important;color:var(--red-fg) !important;font-weight:var(--font-weight-semi-bold) !important;font-size:var(--font-size-large) !important}body>div:last-child>div:last-child[style]:not([class]):not([id])>pre[style]:last-child{border-radius:var(--rounded-corner) !important;background-color:var(--red-bg-1) !important;padding:.75rem 1rem !important;color:var(--red-fg) !important;font-weight:var(--font-weight-normal) !important} diff --git a/modules/amezing/styles/vars.css b/modules/amezing/styles/vars.css new file mode 100644 index 0000000..82e9b14 --- /dev/null +++ b/modules/amezing/styles/vars.css @@ -0,0 +1,20 @@ +:root { + --alert-note-title: "Note"; + --alert-tip-title: "Tip"; + --alert-important-title: "Important"; + --alert-warning-title: "Warning"; + --alert-caution-title: "Caution"; + + --font-sans-serif: "Commissioner"; + --font-system-ui: ui-sans-serif, system-ui, sans-serif; + --font-emoji: "Noto Color Emoji"; + --font-monospace: "Commit Mono"; + + --icon-info: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/info.svg"); + --icon-lightbulb: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/lightbulb.svg"); + --icon-warning-diamond: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/warning-diamond.svg"); + --icon-warning: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/warning.svg"); + --icon-warning-octagon: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/warning-octagon.svg"); + --icon-copy: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/copy.svg"); + --icon-check-square-offset: url("https://unpkg.com/@phosphor-icons/web@2.1.1/src/regular/check-square-offset.svg"); +} diff --git a/modules/analysis/README.md b/modules/analysis/README.md new file mode 100644 index 0000000..95336bf --- /dev/null +++ b/modules/analysis/README.md @@ -0,0 +1 @@ +# Static rendered analysis \ No newline at end of file diff --git a/modules/analysis/index.ts b/modules/analysis/index.ts new file mode 100644 index 0000000..2207c76 --- /dev/null +++ b/modules/analysis/index.ts @@ -0,0 +1,56 @@ +import { chromium } from "playwright"; +import type { Integration } from "muddle/contracts"; +import { inspectDocument } from "./inspect.ts"; + +export interface AuditFinding { + rule: string; + selector: string; + severity: "error" | "warning" | "review"; + message: string; + evidence: Record; +} +export interface AuditReport { + viewport: { width: number; height: number }; + findings: AuditFinding[]; + score: number; + checked: number; + method: "static-rendered-heuristic"; +} +export interface AuditOptions { + viewports?: { width: number; height: number }[]; + ignore?: string[]; + failBelow?: number; + failOnError?: boolean; + includeDisclosures?: boolean; +} + +export async function analyzeHtml(html: string, kind: "accessible" | "overflow", options: AuditOptions = {}): Promise { + const browser = await chromium.launch(); + try { + const reports: AuditReport[] = []; + for (const viewport of options.viewports ?? [{ width: 1440, height: 900 }, { width: 390, height: 844 }]) { + const context = await browser.newContext({ viewport, javaScriptEnabled: false }); + try { + const page = await context.newPage(); + await page.route("**/*", (route) => route.abort()); + await page.setContent(html, { waitUntil: "load" }); + const result = await page.evaluate(inspectDocument, { kind, ignore: options.ignore ?? [], disclosures: options.includeDisclosures ?? false }); + const findings = result.findings as AuditFinding[]; + const score = Math.max(0, Math.round(100 - findings.reduce((sum, finding) => sum + (finding.severity === "error" ? 10 : finding.severity === "warning" ? 4 : 2), 0))); + const report: AuditReport = { viewport, findings, score, checked: result.checked, method: "static-rendered-heuristic" }; + reports.push(report); + if (options.failBelow !== undefined && score < options.failBelow) throw new Error(`${kind} score ${score} below ${options.failBelow} at ${viewport.width}px`); + if (options.failOnError && findings.some((finding) => finding.severity === "error")) throw new Error(`${kind} audit found errors at ${viewport.width}px`); + } finally { await context.close(); } + } + return reports; + } finally { await browser.close(); } +} + +export function accessibleIntegration(options: AuditOptions = {}): Integration { + return { name: "accessible", analyze: (page) => analyzeHtml(page.html, "accessible", options) }; +} + +export function overflowIntegration(options: AuditOptions = {}): Integration { + return { name: "overflow", analyze: (page) => analyzeHtml(page.html, "overflow", options) }; +} diff --git a/modules/analysis/inspect.ts b/modules/analysis/inspect.ts new file mode 100644 index 0000000..d159e94 --- /dev/null +++ b/modules/analysis/inspect.ts @@ -0,0 +1,75 @@ +/** Runs inside the browser against static DOM and resolved CSS, without executing page scripts. */ +export function inspectDocument(options: { kind: "accessible" | "overflow"; ignore: string[]; disclosures: boolean }) { + const { document, getComputedStyle, innerWidth } = globalThis as any; + const findings: { rule: string; selector: string; severity: "error" | "warning" | "review"; message: string; evidence: Record }[] = []; + const elements = [...document.querySelectorAll("body *")]; + if (options.disclosures) for (const element of elements) if (element.tagName === "DETAILS") element.open = true; + const selector = (node: any): string => { + if (node.id) return `[id=${JSON.stringify(node.id)}]`; + const parts: string[] = []; + while (node && node.tagName !== "BODY") { + const index = [...node.parentElement.children].indexOf(node) + 1; + parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`); + node = node.parentElement; + } + return `body > ${parts.join(" > ")}`; + }; + const add = (node: any, rule: string, message: string, evidence: Record = {}, severity: "error" | "warning" | "review" = "error") => findings.push({ selector: selector(node), rule, message, evidence, severity }); + const canvas = document.createElement("canvas"); canvas.width = canvas.height = 1; + const ctx = canvas.getContext("2d", { willReadFrequently: true }); + const color = (value: string): number[] => { ctx.clearRect(0, 0, 1, 1); ctx.fillStyle = value; ctx.fillRect(0, 0, 1, 1); return [...ctx.getImageData(0, 0, 1, 1).data]; }; + const composite = (front: number[], back: number[]): number[] => front.slice(0, 3).map((channel, index) => channel * front[3]! / 255 + back[index]! * (1 - front[3]! / 255)).concat(255); + const luminance = (rgb: number[]): number => rgb.slice(0, 3).map((value) => { const c = value / 255; return c <= .04045 ? c / 12.92 : ((c + .055) / 1.055) ** 2.4; }).reduce((sum, value, index) => sum + value * [.2126, .7152, .0722][index]!, 0); + let checked = 0; + for (const node of elements) { + if (options.ignore.some((ignore) => node.closest(ignore))) continue; + const style = getComputedStyle(node); + const box = node.getBoundingClientRect(); + if (!box.width || !box.height || style.display === "none" || style.visibility !== "visible" || node.closest("[hidden], [inert]")) continue; + checked++; + if (options.kind === "overflow") { + const clippedX = ["hidden", "clip"].includes(style.overflowX) && node.scrollWidth > node.clientWidth + 1; + const clippedY = ["hidden", "clip"].includes(style.overflowY) && node.scrollHeight > node.clientHeight + 1; + const truncated = style.textOverflow === "ellipsis" || (style.webkitLineClamp !== "none" && Number(style.webkitLineClamp) > 0); + if ((clippedX || clippedY) && !truncated) add(node, "clipping", "Content exceeds a clipping boundary", { overflowX: style.overflowX, overflowY: style.overflowY, clientWidth: node.clientWidth, scrollWidth: node.scrollWidth, clientHeight: node.clientHeight, scrollHeight: node.scrollHeight }); + let managed = false; + for (let parent = node.parentElement; parent; parent = parent.parentElement) if (["auto", "scroll", "hidden", "clip"].includes(getComputedStyle(parent).overflowX)) { managed = true; break; } + if (!managed && (box.right > innerWidth + 1 || box.left < -1)) add(node, "viewport-overflow", "Element extends outside the viewport without a containing scroll region", { left: box.left, right: box.right, viewportWidth: innerWidth }); + continue; + } + const label = node.getAttribute("aria-label") || (node.getAttribute("aria-labelledby") || "").split(/\s+/).map((id: string) => document.getElementById(id)?.textContent || "").join("").trim() || [...(node.labels || [])].map((label: any) => label.textContent).join("").trim(); + for (const id of (node.getAttribute("aria-labelledby") || "").split(/\s+/).filter(Boolean)) if (!document.getElementById(id)) add(node, "aria-reference", "aria-labelledby references a missing element", { id }); + if (["A", "BUTTON", "INPUT", "SELECT"].includes(node.tagName) && !node.disabled && (box.width < 24 || box.height < 24)) add(node, "target-size", "Interactive target is smaller than 24 CSS pixels; spacing exceptions require review", { width: box.width, height: box.height }, "review"); + if (node.tagName === "IMG" && !node.hasAttribute("alt")) add(node, "image-alt", "Image lacks an alt attribute"); + if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !["hidden", "submit", "button", "reset"].includes(node.type) && !label) add(node, "control-name", "Form control lacks a programmatic label"); + if (["A", "BUTTON"].includes(node.tagName) && !label && !node.textContent.trim() && !node.querySelector("img[alt]")) add(node, "control-name", "Interactive element lacks an accessible name"); + const ownText = [...node.childNodes].some((child: any) => child.nodeType === 3 && child.textContent.trim()); + if (!ownText) continue; + const chain: any[] = []; let opacity = 1; + for (let parent = node; parent; parent = parent.parentElement) { chain.unshift(parent); opacity *= Number(getComputedStyle(parent).opacity); } + let background = [255, 255, 255, 255]; + const effects: Record[] = []; + for (const parent of chain) { + const css = getComputedStyle(parent); + background = composite(color(css.backgroundColor), background); + if (css.filter !== "none" || css.backdropFilter !== "none" || css.mixBlendMode !== "normal" || css.backgroundImage !== "none" || css.clipPath !== "none" || css.maskImage !== "none") effects.push({ selector: selector(parent), filter: css.filter, backdropFilter: css.backdropFilter, blend: css.mixBlendMode, backgroundImage: css.backgroundImage, clipPath: css.clipPath, maskImage: css.maskImage }); + } + const foreground = color(style.color); foreground[3] = foreground[3]! * opacity; + const text = composite(foreground, background); + const l1 = luminance(text), l2 = luminance(background); + const contrast = (Math.max(l1, l2) + .05) / (Math.min(l1, l2) + .05); + const threshold = parseFloat(style.fontSize) >= 24 || (parseFloat(style.fontSize) >= 18.667 && Number(style.fontWeight) >= 700) ? 3 : 4.5; + if (effects.length) add(node, "visibility-effects", "Filters, backdrop effects or masking require pixel-level review; nominal contrast is not conclusive", { effects, opacity, nominalContrast: contrast }, "review"); + if (contrast < threshold) add(node, "text-contrast", "Estimated text contrast falls below the configured text threshold", { contrast, threshold, opacity, estimate: effects.length > 0 }, effects.length ? "review" : "error"); + if (opacity < .5) add(node, "low-opacity", "Effective ancestor opacity reduces text visibility", { opacity }, "warning"); + } + if (options.kind === "accessible") { + const ids = new Set(); + for (const node of document.querySelectorAll("[id]")) { if (ids.has(node.id)) add(node, "duplicate-id", "Duplicate identifiers make references ambiguous", { id: node.id }); ids.add(node.id); } + let previous = 0; + for (const heading of document.querySelectorAll("h1,h2,h3,h4,h5,h6")) { const level = Number(heading.tagName.slice(1)); if (previous && level > previous + 1) add(heading, "heading-order", "Heading hierarchy skips a level", { previous, level }, "warning"); previous = level; } + if (!document.documentElement.lang) findings.push({ selector: "html", rule: "document-language", severity: "error", message: "Document language is missing", evidence: {} }); + if (!document.title.trim()) findings.push({ selector: "head", rule: "document-title", severity: "error", message: "Document title is missing", evidence: {} }); + } + return { findings, checked }; +} diff --git a/modules/analysis/package.json b/modules/analysis/package.json new file mode 100644 index 0000000..8062ef9 --- /dev/null +++ b/modules/analysis/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/analysis", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "peerDependencies": { "muddle": "*", "playwright": "^1.0.0" } +} diff --git a/modules/astro/index.ts b/modules/astro/index.ts new file mode 100644 index 0000000..1f2665f --- /dev/null +++ b/modules/astro/index.ts @@ -0,0 +1,80 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { transform } from "@astrojs/compiler"; +import { experimental_AstroContainer as AstroContainer } from "astro/container"; +import ts from "typescript"; +import type { Integration, IntegrationContext } from "muddle/contracts"; + +export interface AstroIntegrationOptions { dir?: string; partials?: boolean } + +function routeFor(root: string, file: string): string { + const path = relative(root, file).replace(/\\/g, "/").replace(/\.astro$/, ""); + return `/${path.replace(/index$/, "").replace(/\/+/g, "/") || ""}` || "/"; +} + +export function astroIntegration(options: AstroIntegrationOptions = {}): Integration { + return { + name: "astro", + async setup(context: IntegrationContext) { + context.loader("astro", { name: "astro", extensions: [".astro"], async load(file) { return { data: {}, content: await renderAstro(file.source, undefined, file.path) }; } }); + const dir = resolve(context.config.root, options.dir ?? "src/pages"); + if (!existsSync(dir)) return; + for (const file of await astroFiles(dir)) { + const route = routeFor(dir, file); + context.route("GET", route, async (request) => new Response(await renderAstro(await readFile(file, "utf8"), request, file), { headers: { "content-type": "text/html; charset=utf-8" } })); + } + }, + }; +} + +async function astroFiles(dir: string): Promise { + const files: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) files.push(...(await astroFiles(path))); + else if (entry.name.endsWith(".astro")) files.push(path); + } + return files; +} + +export async function renderAstro(source: string, request = new Request("http://localhost/"), filename = resolve("inline.astro")): Promise { + const component = await compileAstro(source, filename); + const container = await AstroContainer.create(); + return container.renderToString(component, { request }); +} + +async function compileAstro(source: string, filename: string, ancestors: string[] = []): Promise { + if (ancestors.includes(filename)) throw new Error(`Circular Astro import ${filename}`); + const result = await transform(source, { filename, internalURL: "astro/runtime/server/index.js" }); + let code = result.code.replace(/,\s*createMetadata as \$\$createMetadata/, "").replace(/export const \$\$metadata = [^\n]+\n/g, ""); + for (const match of [...code.matchAll(/from\s+["']([^"']+)["']/g)]) { + const specifier = match[1]!; + let url: string; + if (specifier.endsWith(".astro")) { + const file = resolve(filename, "..", specifier); + const child = await compileAstro(await readFile(file, "utf8"), file, [...ancestors, filename]); + const key = `__muddle_astro_${crypto.randomUUID().replaceAll("-", "")}`; + (globalThis as Record)[key] = child; + url = `data:text/javascript;base64,${Buffer.from(`export default globalThis[${JSON.stringify(key)}]`).toString("base64")}`; + } else url = specifier.startsWith(".") ? pathToFileURL(resolve(filename, "..", specifier)).href : import.meta.resolve(specifier); + code = code.replaceAll(`"${specifier}"`, JSON.stringify(url)).replaceAll(`'${specifier}'`, JSON.stringify(url)); + } + code = ts.transpileModule(code, { compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 } }).outputText; + return (await import(`data:text/javascript;base64,${Buffer.from(code).toString("base64")}`)).default; +} + +/** Import into TemplateContext with `.import("Card", astroComponent("./Card.astro"))`. */ +export function astroComponent(file: string) { + return { + async render(props: Record, context: { params: Record; locals: Record }) { + const path = resolve(file); + const component = await compileAstro(await readFile(path, "utf8"), path); + const container = await AstroContainer.create(); + const slots: Record = {}; + for (const [key, value] of Object.entries(props)) if (typeof value === "function") slots[key === "content" ? "default" : key] = await value(); + return container.renderToString(component, { props, params: context.params, locals: context.locals, slots }); + }, + }; +} diff --git a/modules/astro/package.json b/modules/astro/package.json new file mode 100644 index 0000000..8c3e392 --- /dev/null +++ b/modules/astro/package.json @@ -0,0 +1,14 @@ +{ + "name": "@muddle/astro", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "@astrojs/compiler": "2", + "astro": "5", + "typescript": "5.9.3" + } +} diff --git a/modules/database/index.ts b/modules/database/index.ts new file mode 100644 index 0000000..84f7fd1 --- /dev/null +++ b/modules/database/index.ts @@ -0,0 +1,2 @@ +export * from "./src/index.ts"; +export { DatabaseSessionStore } from "./src/sessions.ts"; diff --git a/modules/database/package.json b/modules/database/package.json new file mode 100644 index 0000000..1ea59a8 --- /dev/null +++ b/modules/database/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/database", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "pg": "^8.23.1" + } +} diff --git a/modules/database/src/adapters/d1.ts b/modules/database/src/adapters/d1.ts new file mode 100644 index 0000000..5ac0006 --- /dev/null +++ b/modules/database/src/adapters/d1.ts @@ -0,0 +1,213 @@ +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { FieldType } from "muddle/contracts/typegen/orm/index.ts"; +import { buildCreateTable, type SqlDialect } from "./driver.ts"; + +export interface D1Options { + /** Account the database belongs to. */ + accountId: string; + /** Identifier of the database. */ + databaseId: string; + /** Api token with the `D1 Database:Edit` permission. */ + apiToken: string; + /** Endpoint of the api, useful for tests. */ + endpoint?: string; +} + +const D1_TYPES: Record = { + text: "TEXT", + int: "INTEGER", + bigint: "INTEGER", + real: "REAL", + bool: "INTEGER", + blob: "BLOB", + json: "TEXT", + uuid: "TEXT", + timestamp: "TEXT", + date: "TEXT", +}; + +export const d1Dialect: SqlDialect = { + name: "sqlite", + columnType: (type) => D1_TYPES[type], + placeholder: () => "?", + supportsReturning: true, + defaultExpression: (column) => { + if (column.default === undefined) return undefined; + if (typeof column.default === "number") return String(column.default); + if (typeof column.default === "boolean") return column.default ? "1" : "0"; + return `'${String(column.default).replace(/'/g, "''")}'`; + }, +}; + +interface D1Response { + success: boolean; + result?: { + results?: Record[]; + success?: boolean; + meta?: { changes?: number }; + }[]; + errors?: { message: string }[]; +} + +export class D1Driver implements SqlDriver { + readonly dialect = "sqlite" as const; + readonly supportsReturning = true; + + constructor(private readonly options: D1Options) {} + + /** Placeholder for the n-th parameter (1 based). */ + placeholder(_index: number): string { + return "?"; + } + + /** Runs a statement and returns every row. */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + const response = await this.send(sql, params); + const statement = response.result?.[0]; + return (statement?.results ?? []) as T[]; + } + + /** Runs a statement and returns the number of affected rows. */ + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + const response = await this.send(sql, params); + const statement = response.result?.[0]; + return { changes: statement?.meta?.changes ?? 0 }; + } + + /** + * Runs `fn` inside a transaction. + * + * D1 has no interactive transactions, the statements are collected and + * sent as a single batch, which is atomic. + */ + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + const buffered = new D1Buffer(this.options); + const result = await fn(buffered); + await buffered.flush(); + return result; + } + + /** Creates the table of a model, if it does not exist yet. */ + async createTable( + table: string, + columns: Parameters[2], + options: Parameters[3], + ): Promise { + await this.execute(buildCreateTable(d1Dialect, table, columns, options)); + } + + /** Closes the driver, D1 has no local resources. */ + async close(): Promise {} + + private async send( + sql: string, + params: readonly unknown[], + ): Promise { + const endpoint = + this.options.endpoint ?? + `https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/query`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + authorization: `Bearer ${this.options.apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + sql, + params: params.map((value) => normalize(value)), + }), + }); + + const payload = (await response.json()) as D1Response; + if (!response.ok || payload.success === false) { + const message = payload.errors?.[0]?.message ?? response.statusText; + throw new Error(`D1 request failed: ${message}`); + } + return payload; + } +} + +/** Driver that collects the statements of a transaction. */ +class D1Buffer implements SqlDriver { + readonly dialect = "sqlite" as const; + readonly supportsReturning = true; + + private readonly statements: { sql: string; params: unknown[] }[] = []; + private result: unknown; + + constructor(private readonly options: D1Options) {} + + placeholder(_index: number): string { + return "?"; + } + + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + this.statements.push({ sql, params: [...params] }); + this.result = []; + return [] as T[]; + } + + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + this.statements.push({ sql, params: [...params] }); + return { changes: 0 }; + } + + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + return fn(this); + } + + async close(): Promise {} + + async flush(): Promise { + if (this.statements.length === 0) return; + const endpoint = + this.options.endpoint ?? + `https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/batch`; + + const response = await fetch(endpoint, { + method: "POST", + headers: { + authorization: `Bearer ${this.options.apiToken}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + statements: this.statements.map((statement) => ({ + sql: statement.sql, + params: statement.params.map((value) => normalize(value)), + })), + }), + }); + + if (!response.ok) { + throw new Error(`D1 batch failed: ${response.statusText}`); + } + } +} + +function normalize(value: unknown): unknown { + if (value === undefined || value === null) return null; + if (typeof value === "boolean") return value ? 1 : 0; + if (value instanceof Date) return value.toISOString(); + if (typeof value === "object" && !(value instanceof Uint8Array)) { + return JSON.stringify(value); + } + return value; +} + +/** Creates a Cloudflare D1 driver. */ +export function createD1Driver(options: D1Options): D1Driver { + return new D1Driver(options); +} diff --git a/modules/database/src/adapters/driver.ts b/modules/database/src/adapters/driver.ts new file mode 100644 index 0000000..9ba2967 --- /dev/null +++ b/modules/database/src/adapters/driver.ts @@ -0,0 +1,165 @@ +import type { + ColumnMapping, + FieldType, + TableOptions, +} from "muddle/contracts/typegen/orm/index.ts"; + +/** Anything able to produce a parameter placeholder. */ +export interface PlaceholderSource { + placeholder(index: number): string; +} + +export interface SqlDialect { + readonly name: "sqlite" | "postgres"; + /** SQL type of a field. */ + columnType(type: FieldType): string; + /** Placeholder for the n-th parameter (1 based). */ + placeholder(index: number): string; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning: boolean; + /** Default value expression of a column, if any. */ + defaultExpression(column: ColumnMapping): string | undefined; +} + +/** Quotes an SQL identifier. */ +export function quote(identifier: string): string { + return `"${identifier.replace(/"/g, '""')}"`; +} + +/** Registers a parameter and returns its placeholder. */ +export function bind( + dialect: PlaceholderSource, + params: unknown[], + value: unknown, +): string { + params.push(value); + return dialect.placeholder(params.length); +} + +export interface Condition { + column: string; + operator: string; + value?: unknown; +} + +/** Builds a `WHERE` clause from the condition groups of the query builder. */ +export function buildWhere( + dialect: PlaceholderSource, + groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[], + params: unknown[], +): string { + if (groups.length === 0) return ""; + const parts = groups.map((group) => { + const conditions = group.conditions.map((condition) => + buildCondition(dialect, condition, params), + ); + if (conditions.length === 1) return conditions[0] as string; + return `(${conditions.join(` ${group.conjunction} `)})`; + }); + return ` WHERE ${parts.join(" AND ")}`; +} + +/** Builds a single condition, e.g. `"age" > $1`. */ +export function buildCondition( + dialect: PlaceholderSource, + condition: Condition, + params: unknown[], +): string { + const column = quote(condition.column); + + if ( + condition.operator === "is null" || + condition.operator === "is not null" + ) { + return `${column} ${condition.operator.toUpperCase()}`; + } + + if (condition.operator === "in" || condition.operator === "not in") { + const values = Array.isArray(condition.value) + ? condition.value + : [condition.value]; + if (values.length === 0) { + return condition.operator === "in" ? "1 = 0" : "1 = 1"; + } + const placeholders = values + .map((value) => bind(dialect, params, value)) + .join(", "); + return `${column} ${condition.operator.toUpperCase()} (${placeholders})`; + } + + return `${column} ${condition.operator} ${bind(dialect, params, condition.value)}`; +} + +/** Builds the `CREATE TABLE` statement of a table. */ +export function buildCreateTable( + dialect: SqlDialect, + table: string, + columns: ColumnMapping[], + options: TableOptions, +): string { + const definitions: string[] = []; + + for (const column of columns) { + const parts = [quote(column.column), dialect.columnType(column.type)]; + if (column.primary) parts.push("PRIMARY KEY"); + if (column.unique) parts.push("UNIQUE"); + if (!column.nullable && !column.primary) parts.push("NOT NULL"); + const defaultExpression = dialect.defaultExpression(column); + if (defaultExpression) parts.push(`DEFAULT ${defaultExpression}`); + if (column.references) { + const target = quote(column.references.table); + const targetColumn = column.references.column + ? `${target}(${quote(column.references.column)})` + : undefined; + parts.push( + `REFERENCES ${targetColumn ?? target}${column.references.onDelete ? ` ON DELETE ${column.references.onDelete}` : ""}${ + column.references.onUpdate + ? ` ON UPDATE ${column.references.onUpdate}` + : "" + }`, + ); + } + definitions.push(parts.join(" ")); + } + + if (options.timestamps) { + definitions.push( + `${quote("created_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`, + ); + definitions.push( + `${quote("updated_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`, + ); + } + + const primaryKey = options.primaryKey + ? Array.isArray(options.primaryKey) + ? options.primaryKey + : [options.primaryKey] + : undefined; + + if (primaryKey && primaryKey.length > 0) { + definitions.push(`PRIMARY KEY (${primaryKey.map(quote).join(", ")})`); + } + + const ifNotExists = options.ifNotExists === false ? "" : "IF NOT EXISTS "; + const suffix = options.suffix ? ` ${options.suffix}` : ""; + + return `CREATE TABLE ${ifNotExists}${quote(table)} (\n ${definitions.join(",\n ")}\n)${suffix}`; +} + +/** Normalises a value before it is handed to the driver. */ +export function normalizeValue(value: unknown): unknown { + if (value === undefined) return null; + if (value instanceof Date) return value; + if (typeof value === "boolean") return value; + if (typeof value === "bigint") return value; + if ( + value !== null && + typeof value === "object" && + !Buffer.isBuffer(value) && + !(value instanceof Uint8Array) + ) { + return JSON.stringify(value); + } + return value; +} diff --git a/modules/database/src/adapters/pg.ts b/modules/database/src/adapters/pg.ts new file mode 100644 index 0000000..3e97c4d --- /dev/null +++ b/modules/database/src/adapters/pg.ts @@ -0,0 +1,118 @@ +import { Pool, type PoolClient, type PoolConfig } from "pg"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { FieldType } from "muddle/contracts/typegen/orm/index.ts"; +import { buildCreateTable, type SqlDialect } from "./driver.ts"; + +export type PostgresOptions = PoolConfig | string; + +const POSTGRES_TYPES: Record = { + text: "TEXT", + int: "INTEGER", + bigint: "BIGINT", + real: "DOUBLE PRECISION", + bool: "BOOLEAN", + blob: "BYTEA", + json: "JSONB", + uuid: "UUID", + timestamp: "TIMESTAMPTZ", + date: "DATE", +}; + +export const postgresDialect: SqlDialect = { + name: "postgres", + columnType: (type) => POSTGRES_TYPES[type], + placeholder: (index) => `$${index}`, + supportsReturning: true, + defaultExpression: (column) => { + if (column.default === undefined) return undefined; + if (typeof column.default === "number") return String(column.default); + if (typeof column.default === "boolean") + return column.default ? "TRUE" : "FALSE"; + if (typeof column.default === "object") + return JSON.stringify(column.default); + return `'${String(column.default).replace(/'/g, "''")}'`; + }, +}; + +export class PostgresDriver implements SqlDriver { + readonly dialect = "postgres" as const; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning = true; + private readonly pool: Pool | undefined; + private readonly client?: PoolClient; + + constructor(options: PostgresOptions, client?: PoolClient) { + this.pool = client + ? undefined + : new Pool( + typeof options === "string" ? { connectionString: options } : options, + ); + this.client = client; + } + + private get runner(): Pool | PoolClient { + return this.client ?? (this.pool as Pool); + } + + /** Placeholder for the n-th parameter (1 based). */ + placeholder(index: number): string { + return `$${index}`; + } + + /** Runs a statement and returns every row. */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + const result = await this.runner.query(sql, [...params] as unknown[]); + return result.rows as T[]; + } + + /** Runs a statement and returns the number of affected rows. */ + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + const result = await this.runner.query(sql, [...params] as unknown[]); + return { changes: result.rowCount ?? 0 }; + } + + /** Runs `fn` inside a transaction on a dedicated connection. */ + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + const client = this.client ?? (await (this.pool as Pool).connect()); + const ownsClient = !this.client; + const driver = new PostgresDriver({}, client); + try { + await client.query("BEGIN"); + const result = await fn(driver); + await client.query("COMMIT"); + return result; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + if (ownsClient) client.release(); + } + } + + /** Creates the table of a model, if it does not exist yet. */ + async createTable( + table: string, + columns: Parameters[2], + options: Parameters[3], + ): Promise { + await this.execute( + buildCreateTable(postgresDialect, table, columns, options), + ); + } + + /** Closes the pool. */ + async close(): Promise { + if (this.pool) await this.pool.end(); + } +} + +/** Creates a PostgreSQL driver. */ +export function createPostgresDriver(options: PostgresOptions): PostgresDriver { + return new PostgresDriver(options); +} diff --git a/modules/database/src/adapters/sqlite.ts b/modules/database/src/adapters/sqlite.ts new file mode 100644 index 0000000..b78681e --- /dev/null +++ b/modules/database/src/adapters/sqlite.ts @@ -0,0 +1,130 @@ +import { Database as SqliteDatabase, type SQLQueryBindings } from "bun:sqlite"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { FieldType } from "muddle/contracts/typegen/orm/index.ts"; +import { buildCreateTable, normalizeValue, type SqlDialect } from "./driver.ts"; + +export interface SqliteOptions { + /** Path of the database file, `:memory:` for an in-memory database. */ + path?: string; + /** Opens the database in read only mode. */ + readonly?: boolean; + /** `false` disables the automatic creation of the database file. */ + create?: boolean; +} + +const SQLITE_TYPES: Record = { + text: "TEXT", + int: "INTEGER", + bigint: "INTEGER", + real: "REAL", + bool: "INTEGER", + blob: "BLOB", + json: "TEXT", + uuid: "TEXT", + timestamp: "TEXT", + date: "TEXT", +}; + +export const sqliteDialect: SqlDialect = { + name: "sqlite", + columnType: (type) => SQLITE_TYPES[type], + placeholder: () => "?", + supportsReturning: true, + defaultExpression: (column) => { + if (column.default === undefined) return undefined; + if (typeof column.default === "number") return String(column.default); + if (typeof column.default === "boolean") return column.default ? "1" : "0"; + return `'${String(column.default).replace(/'/g, "''")}'`; + }, +}; + +/** Converts a value into a representation SQLite understands. */ +function toSqliteValue(value: unknown): unknown { + const normalized = normalizeValue(value); + if (normalized === null || normalized === undefined) return null; + if (typeof normalized === "boolean") return normalized ? 1 : 0; + if (normalized instanceof Date) return normalized.toISOString(); + return normalized; +} + +export class SqliteDriver implements SqlDriver { + readonly dialect = "sqlite" as const; + /** `true` when the dialect understands `RETURNING`. */ + readonly supportsReturning = true; + private readonly database: SqliteDatabase; + private depth = 0; + + constructor(options: SqliteOptions = {}) { + this.database = new SqliteDatabase(options.path ?? ":memory:", { + readonly: options.readonly ?? false, + create: options.create ?? true, + }); + } + + /** Placeholder for the n-th parameter (1 based). */ + placeholder(_index: number): string { + return "?"; + } + + /** Runs a statement and returns every row. */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + const statement = this.database.query(sql); + return statement.all( + ...(params.map(toSqliteValue) as SQLQueryBindings[]), + ) as T[]; + } + + /** Runs a statement and returns the number of affected rows. */ + async execute( + sql: string, + params: readonly unknown[] = [], + ): Promise<{ changes: number }> { + const statement = this.database.query(sql); + const info = statement.run( + ...(params.map(toSqliteValue) as SQLQueryBindings[]), + ); + return { changes: info.changes }; + } + + /** Runs `fn` inside a transaction, nested calls use savepoints. */ + async transaction(fn: (driver: SqlDriver) => Promise): Promise { + const nested = this.depth > 0; + const savepoint = `muddle_sp_${this.depth}`; + await this.execute(nested ? `SAVEPOINT ${savepoint}` : "BEGIN"); + this.depth += 1; + try { + const result = await fn(this); + this.depth -= 1; + await this.execute(nested ? `RELEASE ${savepoint}` : "COMMIT"); + return result; + } catch (error) { + this.depth -= 1; + await this.execute(nested ? `ROLLBACK TO ${savepoint}` : "ROLLBACK"); + throw error; + } + } + + /** Creates the table of a model, if it does not exist yet. */ + async createTable( + table: string, + columns: Parameters[2], + options: Parameters[3], + ): Promise { + await this.execute( + buildCreateTable(sqliteDialect, table, columns, options), + ); + } + + /** Closes the database. */ + async close(): Promise { + this.database.close(); + } +} + +/** Creates a SQLite driver. */ +export function createSqliteDriver(options: SqliteOptions = {}): SqliteDriver { + return new SqliteDriver(options); +} diff --git a/modules/database/src/database.ts b/modules/database/src/database.ts new file mode 100644 index 0000000..ff889e8 --- /dev/null +++ b/modules/database/src/database.ts @@ -0,0 +1,55 @@ +import type { + ColumnMapping, + SqlDriver, + TableOptions, +} from "muddle/contracts/typegen/orm/index.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; +import { getTableMetadata } from "./decorators/Table.ts"; +import { Repository } from "./repository.ts"; + +/** + * Database. + * + * Owns the driver, keeps track of the registered models and creates the + * tables of those models. + */ +export class Database { + private readonly models = new Map>(); + + constructor(readonly driver: SqlDriver) {} + + /** Registers a model and returns its repository. */ + model(model: TableClass): Repository { + const metadata = getTableMetadata(model as unknown as object); + this.models.set(metadata.name, model as unknown as TableClass); + return new Repository(this.driver, model); + } + + /** Creates the tables of every registered model. */ + async migrate(): Promise { + for (const model of this.models.values()) { + const metadata = getTableMetadata(model as unknown as object); + const columns = [...metadata.fields.values()]; + const driver = this.driver as SqlDriver & { + createTable?: ( + table: string, + columns: ColumnMapping[], + options: TableOptions, + ) => Promise; + }; + if (driver.createTable) { + await driver.createTable(metadata.name, columns, metadata.options); + } + } + } + + /** Runs `fn` inside a transaction. */ + async transaction(fn: (database: Database) => Promise): Promise { + return this.driver.transaction(async (driver) => fn(new Database(driver))); + } + + /** Closes the underlying driver. */ + async close(): Promise { + await this.driver.close(); + } +} diff --git a/modules/database/src/decorators/Field.ts b/modules/database/src/decorators/Field.ts new file mode 100644 index 0000000..bbf8272 --- /dev/null +++ b/modules/database/src/decorators/Field.ts @@ -0,0 +1,40 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; +import type { FieldOptions } from "muddle/contracts/typegen/orm/field.ts"; +import type { FieldDescriptor } from "muddle/contracts/typegen/orm/field.ts"; + +export const FIELD_METADATA_KEY = "muddle:orm:fields"; + +/** Converts a property name into the default column name. */ +export function toColumnName(property: string): string { + return property + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2") + .toLowerCase(); +} + +/** Declares the column backing a property. */ +export function Field( + options: FieldOptions, +): PropertyDecorator { + return (target, propertyKey) => { + const constructor = target.constructor as object; + const fields = getFields(constructor); + const property = String(propertyKey); + fields.set(property, { + property, + column: options.column ?? toColumnName(property), + options, + }); + }; +} + +/** Reads the fields declared on a model class. */ +export function getFields(target: object): Map { + const existing = getMetadata(FIELD_METADATA_KEY, target) as + | Map + | undefined; + if (existing) return existing; + const fields = new Map(); + defineMetadata(FIELD_METADATA_KEY, fields, target); + return fields; +} diff --git a/modules/database/src/decorators/Hydrate.ts b/modules/database/src/decorators/Hydrate.ts new file mode 100644 index 0000000..76e0b95 --- /dev/null +++ b/modules/database/src/decorators/Hydrate.ts @@ -0,0 +1,42 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; + +export const HYDRATE_METADATA_KEY = "muddle:orm:hydrated"; + +export interface HydrateOptions { + /** Computes the value from the raw database row. */ + decode?: (row: Record) => Value; + /** Column the value is derived from, handed to `decode` as second argument. */ + column?: string; +} + +export interface HydrateDescriptor { + property: string; + options: HydrateOptions; +} + +/** Declares a hydrated (computed) property. */ +export function Hydrate( + options: HydrateOptions = {}, +): PropertyDecorator { + return (target, propertyKey) => { + const constructor = target.constructor as object; + const hydrated = getHydratedProperties(constructor); + hydrated.set(String(propertyKey), { + property: String(propertyKey), + options: options as HydrateOptions, + }); + }; +} + +/** Reads the hydrated properties declared on a model class. */ +export function getHydratedProperties( + target: object, +): Map { + const existing = getMetadata(HYDRATE_METADATA_KEY, target) as + | Map + | undefined; + if (existing) return existing; + const hydrated = new Map(); + defineMetadata(HYDRATE_METADATA_KEY, hydrated, target); + return hydrated; +} diff --git a/modules/database/src/decorators/Table.ts b/modules/database/src/decorators/Table.ts new file mode 100644 index 0000000..ff19ebb --- /dev/null +++ b/modules/database/src/decorators/Table.ts @@ -0,0 +1,85 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; +import type { ColumnMapping } from "muddle/contracts/typegen/orm/field.ts"; +import type { TableOptions } from "muddle/contracts/typegen/orm/table.ts"; +import { getFields } from "./Field.ts"; +import { getHydratedProperties } from "./Hydrate.ts"; + +export const TABLE_METADATA_KEY = "muddle:orm:table"; + +export interface TableMetadata { + /** Table name. */ + name: string; + options: TableOptions; + /** Persisted fields, in declaration order. */ + fields: Map; + /** Raw field descriptors, including the encode/decode transformations. */ + descriptors: Map< + string, + { property: string; column: string; options: unknown } + >; + /** Computed properties, hydrated but never persisted. */ + hydrated: Map; +} + +/** Declares the table backing a model class. */ +export function Table( + name: string, + options: TableOptions = {}, +): ClassDecorator { + return (target) => { + const constructor = target as unknown as object; + const descriptors = getFields(constructor); + const hydrated = getHydratedProperties(constructor); + + const fields = new Map(); + for (const [property, descriptor] of descriptors) { + const fieldOptions = descriptor.options as { + type: ColumnMapping["type"]; + primary?: boolean; + unique?: boolean; + nullable?: boolean; + default?: unknown; + references?: ColumnMapping["references"]; + }; + fields.set(property, { + property, + column: descriptor.column, + type: fieldOptions.type, + nullable: fieldOptions.nullable === true, + primary: fieldOptions.primary === true, + unique: fieldOptions.unique === true, + default: fieldOptions.default, + references: fieldOptions.references, + }); + } + + const metadata: TableMetadata = { + name, + options, + fields, + descriptors: descriptors as TableMetadata["descriptors"], + hydrated: hydrated as TableMetadata["hydrated"], + }; + + defineMetadata(TABLE_METADATA_KEY, metadata, constructor); + return target; + }; +} + +/** Reads the table metadata of a model class. */ +export function getTableMetadata(target: object): TableMetadata { + const metadata = getMetadata(TABLE_METADATA_KEY, target) as + | TableMetadata + | undefined; + if (!metadata) { + throw new Error( + `${(target as { name?: string }).name ?? "model"} is not decorated with @Table()`, + ); + } + return metadata; +} + +/** `true` when the class is a table model. */ +export function isTable(target: object): boolean { + return getMetadata(TABLE_METADATA_KEY, target) !== undefined; +} diff --git a/modules/database/src/hydration.ts b/modules/database/src/hydration.ts new file mode 100644 index 0000000..8fac546 --- /dev/null +++ b/modules/database/src/hydration.ts @@ -0,0 +1,81 @@ +import { getTableMetadata } from "./decorators/Table.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; + +function createInstance(model: TableClass): T { + try { + return new (model as unknown as new () => T)(); + } catch { + return Object.create(model.prototype) as T; + } +} + +/** Hydrates a raw database row into a model instance. */ +export function hydrateRow( + model: TableClass, + row: Record, +): T { + const metadata = getTableMetadata(model as unknown as object); + const instance = createInstance(model); + const target = instance as unknown as Record; + + for (const [property, descriptor] of metadata.descriptors) { + const raw = row[descriptor.column]; + const options = descriptor.options as { + decode?: (value: unknown) => unknown; + }; + target[property] = options.decode ? options.decode(raw) : raw; + } + + for (const [property, descriptor] of metadata.hydrated) { + const options = descriptor.options as { + decode?: (row: Record) => unknown; + column?: string; + }; + let value: unknown; + if (options.decode) { + value = options.decode(row); + } else { + const getter = Object.getOwnPropertyDescriptor( + model.prototype, + property, + )?.get; + value = getter ? getter.call(instance) : undefined; + } + Object.defineProperty(target, property, { + value, + enumerable: true, + writable: true, + configurable: true, + }); + } + + return instance; +} + +/** Encodes a model value into the values written to the database. */ +export function encodeRow( + model: TableClass, + data: Partial | Record, +): Record { + const metadata = getTableMetadata(model as unknown as object); + const encoded: Record = {}; + + for (const [key, value] of Object.entries(data as Record)) { + const descriptor = metadata.descriptors.get(key); + if (!descriptor) continue; + const options = descriptor.options as { + encode?: (value: unknown) => unknown; + }; + encoded[descriptor.column] = options.encode ? options.encode(value) : value; + } + + return encoded; +} + +/** Hydrates a list of raw rows. */ +export function hydrateRows( + model: TableClass, + rows: Record[], +): T[] { + return rows.map((row) => hydrateRow(model, row)); +} diff --git a/modules/database/src/index.ts b/modules/database/src/index.ts new file mode 100644 index 0000000..08fd40e --- /dev/null +++ b/modules/database/src/index.ts @@ -0,0 +1,68 @@ +import type { PoolConfig } from "pg"; +import { createD1Driver, D1Driver } from "./adapters/d1.ts"; +import { createPostgresDriver, PostgresDriver } from "./adapters/pg.ts"; +import { createSqliteDriver } from "./adapters/sqlite.ts"; +import { Database } from "./database.ts"; + +export * from "./adapters/d1.ts"; +export * from "./adapters/driver.ts"; +export * from "./adapters/pg.ts"; +export * from "./adapters/sqlite.ts"; +export * from "./database.ts"; +export * from "./decorators/Field.ts"; +export * from "./decorators/Hydrate.ts"; +export * from "./decorators/Table.ts"; +export * from "./hydration.ts"; +export * from "./repository.ts"; +export * from "./migrations.ts"; + +export interface SqliteDatabaseOptions { + dialect: "sqlite"; + /** Path of the database file, `:memory:` for an in-memory database. */ + path?: string; + readonly?: boolean; +} + +export interface PostgresDatabaseOptions { + dialect: "postgres"; + /** Connection string, e.g. `postgres://user:pass@localhost:5432/db`. */ + url?: string; + /** Connection options, used when no url is given. */ + connection?: PoolConfig; +} + +export interface D1DatabaseOptions { + dialect: "d1"; + /** Account the database belongs to. */ + accountId: string; + /** Identifier of the database. */ + databaseId: string; + /** Api token of the account. */ + apiToken: string; + /** Endpoint of the api, useful for tests. */ + endpoint?: string; +} + +/** Creates a database backed by SQLite or PostgreSQL. */ +export function createDatabase( + options: SqliteDatabaseOptions | PostgresDatabaseOptions | D1DatabaseOptions, +): Database { + if (options.dialect === "sqlite") { + return new Database( + createSqliteDriver({ path: options.path, readonly: options.readonly }), + ); + } + if (options.dialect === "d1") { + const driver: D1Driver = createD1Driver({ + accountId: options.accountId, + databaseId: options.databaseId, + apiToken: options.apiToken, + endpoint: options.endpoint, + }); + return new Database(driver); + } + const driver: PostgresDriver = options.url + ? createPostgresDriver(options.url) + : createPostgresDriver(options.connection ?? {}); + return new Database(driver); +} diff --git a/modules/database/src/migrations.ts b/modules/database/src/migrations.ts new file mode 100644 index 0000000..0666974 --- /dev/null +++ b/modules/database/src/migrations.ts @@ -0,0 +1,43 @@ +import type { TableClass } from "muddle/contracts/typegen/orm/index.ts"; +import { getTableMetadata } from "./decorators/Table.ts"; + +export interface SchemaColumn { name: string; type: string; nullable: boolean } +export interface SchemaTable { name: string; columns: SchemaColumn[] } +export interface Migration { up: string[]; down: string[] } + +export function schemaOf(tables: TableClass[]): SchemaTable[] { + return tables.map((table) => { + const metadata = getTableMetadata(table); + if (!metadata) throw new Error(`Missing @Table metadata for ${table.name}`); + return { name: metadata.name, columns: [...metadata.fields.values()].map((field) => ({ name: field.column, type: field.type, nullable: field.nullable })) }; + }); +} + +export function diffSchema(previous: SchemaTable[], next: SchemaTable[]): Migration { + const oldTables = new Map(previous.map((table) => [table.name, table])); + const nextTables = new Map(next.map((table) => [table.name, table])); + const up: string[] = []; + const down: string[] = []; + for (const table of next) { + const old = oldTables.get(table.name); + if (!old) { + up.push(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`); + down.unshift(`DROP TABLE ${table.name};`); + continue; + } + const oldColumns = new Map(old.columns.map((column) => [column.name, column])); + for (const column of table.columns) if (!oldColumns.has(column.name)) { + up.push(`ALTER TABLE ${table.name} ADD COLUMN ${columnSql(column)};`); + down.unshift(`ALTER TABLE ${table.name} DROP COLUMN ${column.name};`); + } + } + for (const table of previous) if (!nextTables.has(table.name)) { + up.push(`DROP TABLE ${table.name};`); + down.unshift(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`); + } + return { up, down }; +} + +function columnSql(column: SchemaColumn): string { + return `${column.name} ${column.type.toUpperCase()}${column.nullable ? "" : " NOT NULL"}`; +} diff --git a/modules/database/src/repository.ts b/modules/database/src/repository.ts new file mode 100644 index 0000000..621c14d --- /dev/null +++ b/modules/database/src/repository.ts @@ -0,0 +1,281 @@ +import type { + ColumnName, + OrderDirection, + Repository as RepositoryContract, + WhereCondition, + WhereInput, + WhereOperator, +} from "muddle/contracts/typegen/orm/index.ts"; +import { WHERE_OPERATORS } from "muddle/contracts/typegen/orm/index.ts"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; +import { getTableMetadata } from "./decorators/Table.ts"; +import { encodeRow, hydrateRow, hydrateRows } from "./hydration.ts"; +import { buildWhere, quote, type Condition } from "./adapters/driver.ts"; + +interface QueryState { + groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[]; + order: { column: string; direction: OrderDirection }[]; + limit?: number; + offset?: number; + select?: string[]; +} + +const EMPTY_STATE: QueryState = { groups: [], order: [] }; + +function isOperator(value: unknown): value is WhereOperator { + return ( + typeof value === "string" && + (WHERE_OPERATORS as readonly string[]).includes(value) + ); +} + +export class Repository implements RepositoryContract { + constructor( + private readonly driver: SqlDriver, + private readonly model: TableClass, + private readonly state: QueryState = EMPTY_STATE, + ) {} + + private get table(): string { + return getTableMetadata(this.model as unknown as object).name; + } + + private clone(state: Partial): this { + return new Repository(this.driver, this.model, { + ...this.state, + ...state, + }) as unknown as this; + } + + /** Restricts the selected columns. */ + select(...columns: ColumnName[]): this { + return this.clone({ + select: columns.length > 0 ? [...columns] : undefined, + }); + } + + /** Adds a condition. */ + where(conditions: WhereCondition[]): this; + where(column: ColumnName, value: unknown): this; + where(column: ColumnName, operator: WhereOperator, value?: unknown): this; + where( + columnOrConditions: ColumnName | WhereCondition[], + operatorOrValue?: WhereOperator | unknown, + value?: unknown, + ): this { + if (Array.isArray(columnOrConditions)) { + return this.and(columnOrConditions); + } + const column = String(columnOrConditions); + if (isOperator(operatorOrValue)) { + return this.clone({ + groups: [ + ...this.state.groups, + { + conditions: [{ column, operator: operatorOrValue, value }], + conjunction: "AND", + }, + ], + }); + } + return this.clone({ + groups: [ + ...this.state.groups, + { + conditions: [{ column, operator: "=", value: operatorOrValue }], + conjunction: "AND", + }, + ], + }); + } + + /** Adds a group of conditions combined with `AND`. */ + and(conditions: WhereCondition[]): this { + return this.clone({ + groups: [ + ...this.state.groups, + { conditions: [...conditions], conjunction: "AND" }, + ], + }); + } + + /** Adds a group of conditions combined with `OR`. */ + or(conditions: WhereCondition[]): this { + return this.clone({ + groups: [ + ...this.state.groups, + { conditions: [...conditions], conjunction: "OR" }, + ], + }); + } + + /** Orders the result set. */ + orderBy(column: ColumnName, direction: OrderDirection = "asc"): this { + return this.clone({ + order: [...this.state.order, { column: String(column), direction }], + }); + } + + /** Limits the result set. */ + limit(count: number): this { + return this.clone({ limit: count }); + } + + /** Skips rows. */ + offset(count: number): this { + return this.clone({ offset: count }); + } + + /** + * Runs a raw statement and returns the rows. + * + * The rows are not hydrated, the method is meant for the statements the + * repository does not cover. + */ + async query>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + return this.driver.query(sql, params); + } + + /** Executes the query and hydrates every row. */ + async all(): Promise { + const params: unknown[] = []; + const columns = this.state.select + ? this.state.select + .map((column) => quote(this.columnOf(column))) + .join(", ") + : "*"; + const where = buildWhere(this.driver, this.state.groups, params); + const order = this.buildOrder(); + const limit = this.buildLimit(params); + const sql = `SELECT ${columns} FROM ${quote(this.table)}${where}${order}${limit}`; + const rows = await this.driver.query>(sql, params); + return hydrateRows(this.model, rows); + } + + /** Alias of {@link all}. */ + async find(where?: WhereInput): Promise { + const repository = where ? this.applyWhereInput(where) : this; + return repository.all(); + } + + /** Executes the query and hydrates the first row. */ + async first(): Promise { + const rows = await this.limit(1).all(); + return rows[0] ?? null; + } + + /** Alias of {@link first}. */ + async findOne(where?: WhereInput): Promise { + const repository = where ? this.applyWhereInput(where) : this; + return repository.first(); + } + + /** Counts the rows matching the current conditions. */ + async count(): Promise { + const params: unknown[] = []; + const where = buildWhere(this.driver, this.state.groups, params); + const sql = `SELECT COUNT(*) AS ${quote("count")} FROM ${quote(this.table)}${where}`; + const rows = await this.driver.query<{ count: string | number }>( + sql, + params, + ); + const value = rows[0]?.count ?? 0; + return Number(value); + } + + /** Inserts a row, encoding every value before the commit. */ + async create(data: Partial): Promise { + const encoded = encodeRow(this.model, data); + const columns = Object.keys(encoded); + if (columns.length === 0) { + throw new Error(`Cannot insert an empty row into ${this.table}`); + } + const params: unknown[] = []; + const placeholders = columns.map((column) => { + params.push(encoded[column]); + return this.driver.placeholder(params.length); + }); + const returning = this.driver.supportsReturning ? " RETURNING *" : ""; + const sql = `INSERT INTO ${quote(this.table)} (${columns.map(quote).join(", ")}) VALUES (${placeholders.join(", ")})${returning}`; + const rows = await this.driver.query>(sql, params); + const row = rows[0]; + if (!row) { + throw new Error(`Insert into ${this.table} did not return a row`); + } + return hydrateRow(this.model, row); + } + + /** Updates the matching rows, encoding every value before the commit. */ + async update(data: Partial): Promise { + const encoded = encodeRow(this.model, data); + const columns = Object.keys(encoded); + if (columns.length === 0) return 0; + const params: unknown[] = []; + const assignments = columns.map((column) => { + params.push(encoded[column]); + return `${quote(column)} = ${this.driver.placeholder(params.length)}`; + }); + const where = buildWhere(this.driver, this.state.groups, params); + const sql = `UPDATE ${quote(this.table)} SET ${assignments.join(", ")}${where}`; + const result = await this.driver.execute(sql, params); + return result.changes; + } + + /** Deletes the matching rows. */ + async delete(): Promise { + const params: unknown[] = []; + const where = buildWhere(this.driver, this.state.groups, params); + const sql = `DELETE FROM ${quote(this.table)}${where}`; + const result = await this.driver.execute(sql, params); + return result.changes; + } + + private get dialect(): SqlDriver["dialect"] { + return this.driver.dialect; + } + + private applyWhereInput(where: WhereInput): this { + if (Array.isArray(where)) { + return this.and(where); + } + const conditions: WhereCondition[] = Object.entries(where).map( + ([column, value]) => ({ + column, + operator: "=", + value, + }), + ); + return this.and(conditions); + } + + private columnOf(column: string): string { + const metadata = getTableMetadata(this.model as unknown as object); + return metadata.fields.get(column)?.column ?? column; + } + + private buildOrder(): string { + if (this.state.order.length === 0) return ""; + const parts = this.state.order.map( + (entry) => + `${quote(this.columnOf(entry.column))} ${entry.direction.toUpperCase()}`, + ); + return ` ORDER BY ${parts.join(", ")}`; + } + + private buildLimit(params: unknown[]): string { + let sql = ""; + if (this.state.limit !== undefined) { + params.push(this.state.limit); + sql += ` LIMIT ${this.driver.placeholder(params.length)}`; + } + if (this.state.offset !== undefined) { + params.push(this.state.offset); + sql += ` OFFSET ${this.driver.placeholder(params.length)}`; + } + return sql; + } +} diff --git a/modules/database/src/sessions.ts b/modules/database/src/sessions.ts new file mode 100644 index 0000000..f4e8898 --- /dev/null +++ b/modules/database/src/sessions.ts @@ -0,0 +1,35 @@ +import type { SessionRecord, SessionStore } from "muddle/sessions"; +import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts"; + +/** Persistent sessions and atomic OAuth transaction consumption for SQLite, D1 and Postgres. */ +export class DatabaseSessionStore implements SessionStore { + private constructor(private readonly driver: SqlDriver, private readonly table: string) {} + + static async create(driver: SqlDriver, table = "muddle_sessions"): Promise { + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error("Invalid session table name"); + await driver.execute(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL, expires BIGINT NOT NULL)`); + return new DatabaseSessionStore(driver, table); + } + + async get(key: string): Promise { + const rows = await this.driver.query<{ value: string; expires: number }>(`SELECT value, expires FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); + const row = rows[0]; + if (!row || Number(row.expires) <= Date.now()) { await this.delete(key); return undefined; } + return { value: JSON.parse(row.value), expires: Number(row.expires) }; + } + + async set(key: string, record: SessionRecord): Promise { + const args = [key, JSON.stringify(record.value), record.expires]; + await this.driver.execute(`INSERT INTO "${this.table}" (id, value, expires) VALUES (${args.map((_, index) => this.driver.placeholder(index + 1)).join(", ")}) ON CONFLICT (id) DO UPDATE SET value = excluded.value, expires = excluded.expires`, args); + } + + async delete(key: string): Promise { await this.driver.execute(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); } + + async consume(key: string): Promise { + const rows = await this.driver.query<{ value: string; expires: number }>(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)} RETURNING value, expires`, [key]); + const row = rows[0]; + return row && Number(row.expires) > Date.now() ? { value: JSON.parse(row.value), expires: Number(row.expires) } : undefined; + } + + async prune(): Promise { await this.driver.execute(`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`, [Date.now()]); } +} diff --git a/modules/latex/index.ts b/modules/latex/index.ts new file mode 100644 index 0000000..39dfa2d --- /dev/null +++ b/modules/latex/index.ts @@ -0,0 +1,39 @@ +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { Integration, IntegrationContext } from "muddle/contracts"; +import { renderMarkdown } from "muddle/markdown"; +import katex from "katex"; + +export function renderLatex(source: string, displayMode = false): string { + return katex.renderToString(source, { displayMode, throwOnError: false, trust: false }); +} + +export function renderMathMarkdown(source: string): string { + const formulas: string[] = []; + const body = source.replace(/\$\$([\s\S]+?)\$\$|(? { + formulas.push(renderLatex(display ?? inline, display !== undefined)); + return `MUDDLEMATH${formulas.length - 1}END`; + }); + return renderMarkdown(body).replace(/MUDDLEMATH(\d+)END/g, (_match, index) => formulas[Number(index)] ?? ""); +} + +export interface LatexIntegrationOptions { route?: string } + +export function latexIntegration(options: LatexIntegrationOptions = {}): Integration { + const route = options.route ?? "/_muddle/katex.css"; + return { + name: "latex", + async setup(context: IntegrationContext) { + const base = dirname(fileURLToPath(import.meta.resolve("katex/package.json"))); + const css = await readFile(join(base, "dist", "katex.min.css"), "utf8"); + context.loader("latex-markdown", { + name: "latex-markdown", + extensions: [".latex.md", ".latex.markdown"], + load(file) { return { data: {}, content: renderMathMarkdown(file.source) }; }, + }); + context.route("GET", route, () => new Response(css, { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=31536000, immutable" } })); + }, + }; +} + diff --git a/modules/latex/package.json b/modules/latex/package.json new file mode 100644 index 0000000..0f3ef7f --- /dev/null +++ b/modules/latex/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/latex", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "katex": "^0.19.0" + } +} diff --git a/modules/mdx/index.ts b/modules/mdx/index.ts new file mode 100644 index 0000000..c0f760d --- /dev/null +++ b/modules/mdx/index.ts @@ -0,0 +1,14 @@ +import { evaluate } from "@mdx-js/mdx"; +import * as runtime from "react/jsx-runtime"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import type { Integration } from "muddle/contracts"; + +export async function renderMdx(source: string): Promise { + const module = await evaluate(source, { ...runtime }); + return renderToStaticMarkup(createElement(module.default)); +} + +export function mdxIntegration(): Integration { + return { name: "mdx", setup(context) { context.loader("mdx", { name: "mdx", extensions: [".mdx"], async load(file) { return { data: {}, content: await renderMdx(file.source) }; } }); } }; +} diff --git a/modules/mdx/package.json b/modules/mdx/package.json new file mode 100644 index 0000000..e53c043 --- /dev/null +++ b/modules/mdx/package.json @@ -0,0 +1,18 @@ +{ + "name": "@muddle/mdx", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "@mdx-js/mdx": "^3.1.1", + "react": "^19.3.1", + "react-dom": "^19.3.1" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0" + } +} diff --git a/modules/optimize/README.md b/modules/optimize/README.md new file mode 100644 index 0000000..da43184 --- /dev/null +++ b/modules/optimize/README.md @@ -0,0 +1 @@ +# Build optimization \ No newline at end of file diff --git a/modules/optimize/index.ts b/modules/optimize/index.ts new file mode 100644 index 0000000..6e42db8 --- /dev/null +++ b/modules/optimize/index.ts @@ -0,0 +1,88 @@ +import type { Integration, IntegrationContext } from "muddle/contracts"; + +import * as terser from "terser"; +import * as svgo from "svgo"; +import * as csso from "csso"; +import * as htmlMinifier from "html-minifier-terser"; + +export function minifyCss(source: string): string { + return csso.minify(source).css; +} + +export async function minifyHtml(source: string): Promise { + return await htmlMinifier.minify(source, { + collapseWhitespace: true, + removeComments: true, + removeRedundantAttributes: true, + removeEmptyAttributes: true, + removeOptionalTags: true, + }); +} + + +export async function minifyJavaScript(source: string): Promise { + return (await terser.minify(source, { format: { comments: false } })).code ?? source; +} + +export function optimizeSvg(source: string): string { + return svgo.optimize(source, { multipass: true }).data; +} + +export interface ImageOptimizer { + optimize(source: Uint8Array, type: string): Promise | Uint8Array; +} + +export function imageOptimization(optimizer: ImageOptimizer): ImageOptimizer { + return optimizer; +} + +function loaderIntegration(name: string, extension: string, transform: (source: string) => string | Promise): Integration { + return { + name, + async transform(asset) { + if (!asset.path.endsWith(extension)) return asset; + const source = typeof asset.content === "string" ? asset.content : new TextDecoder().decode(asset.content); + return { ...asset, content: await transform(source) }; + }, + setup(context: IntegrationContext) { + context.loader(name, { name, extensions: [extension], async load(file) { return { data: {}, content: await transform(file.source) }; } }); + }, + }; +} + +export function terserIntegration(): Integration { + return loaderIntegration("terser", ".js", minifyJavaScript); +} + +export function cssMinifyIntegration(): Integration { + return loaderIntegration("css-minify", ".css", minifyCss); +} + +export function htmlMinifyIntegration(): Integration { + return loaderIntegration("html-minify", ".html", minifyHtml); +} + +export function svgoIntegration(): Integration { + return loaderIntegration("svgo", ".svg", optimizeSvg); +} + +export function imageOptimizationIntegration(optimizer: ImageOptimizer): Integration { + return { + name: "image-optimization", + async transform(asset) { + if (!/\.(png|jpe?g|webp|avif)$/i.test(asset.path)) return asset; + const input = typeof asset.content === "string" ? new TextEncoder().encode(asset.content) : asset.content; + return { ...asset, content: await optimizer.optimize(input, asset.contentType) }; + }, + setup(context: IntegrationContext) { + context.loader("image", { + name: "image", + extensions: [".png", ".jpg", ".jpeg", ".webp", ".avif"], + async load(file) { + const optimized = await optimizer.optimize(new TextEncoder().encode(file.source), "image/*"); + return { data: { optimized }, content: file.source }; + }, + }); + }, + }; +} diff --git a/modules/optimize/package.json b/modules/optimize/package.json new file mode 100644 index 0000000..175d648 --- /dev/null +++ b/modules/optimize/package.json @@ -0,0 +1,17 @@ +{ + "name": "@muddle/optimize", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "dependencies": { + "terser": "^5.51.2", + "svgo": "^4.1.0", + "csso": "^5.0.5", + "html-minifier-terser": "^7.2.0", + "@types/csso": "^5.0.5", + "@types/html-minifier-terser": "^7.0.2" + }, + "peerDependencies": { + "muddle": "*" + } +} diff --git a/modules/prettier-plugin/index.ts b/modules/prettier-plugin/index.ts new file mode 100644 index 0000000..bafeebd --- /dev/null +++ b/modules/prettier-plugin/index.ts @@ -0,0 +1,21 @@ +import type { Plugin, Parser } from "prettier"; +import { formatSource, type FormatLanguage } from "muddle/formatter"; + +interface Document { formatted: string } +const parser = (language: FormatLanguage): Parser => ({ + astFormat: "muddle-document", + async parse(source, options) { return { formatted: await formatSource(source, language, options) }; }, + locStart: () => 0, + locEnd: (node) => node.formatted.length, +}); + +const plugin: Plugin = { + languages: [ + { name: "SPUG", parsers: ["spug"], extensions: [".spug", ".stpug", ".stug"], vscodeLanguageIds: ["spug"] }, + { name: "STCSS", parsers: ["stcss"], extensions: [".stcss"], vscodeLanguageIds: ["stcss"] }, + ], + parsers: { spug: parser("spug"), stcss: parser("stcss") }, + printers: { "muddle-document": { print: (path) => path.node.formatted } }, +}; +export default plugin; +export const { languages, parsers, printers } = plugin; diff --git a/modules/prettier-plugin/package.json b/modules/prettier-plugin/package.json new file mode 100644 index 0000000..fc124ef --- /dev/null +++ b/modules/prettier-plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/prettier-plugin", + "version": "0.1.0", + "type": "module", + "exports": "./index.ts", + "peerDependencies": { "muddle": "*", "prettier": "^3.0.0" } +} diff --git a/modules/shadcn/index.ts b/modules/shadcn/index.ts new file mode 100644 index 0000000..37ef6f5 --- /dev/null +++ b/modules/shadcn/index.ts @@ -0,0 +1 @@ +export { shadcnIntegration, type ShadcnIntegrationOptions } from "../../integrations/shadcn.ts"; diff --git a/modules/shadcn/package.json b/modules/shadcn/package.json new file mode 100644 index 0000000..06e46fe --- /dev/null +++ b/modules/shadcn/package.json @@ -0,0 +1,7 @@ +{ + "name": "@muddle/shadcn", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { "muddle": "*" } +} diff --git a/modules/tailwind/index.ts b/modules/tailwind/index.ts new file mode 100644 index 0000000..0b5eaeb --- /dev/null +++ b/modules/tailwind/index.ts @@ -0,0 +1,62 @@ +import { readdir, readFile } from "node:fs/promises"; +import { dirname, extname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { compile } from "tailwindcss"; +import type { Integration, IntegrationContext } from "muddle/contracts"; + +export interface TailwindOptions { + route?: string; + css?: string; + content?: string[]; +} + +const SCANNED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".html", ".spug", ".mdx"]); + +export async function tailwindCandidates(root: string, paths?: string[]): Promise { + const candidates = new Set(); + const visit = async (dir: string): Promise => { + for (const entry of await readdir(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) { await visit(path); continue; } + if (!SCANNED_EXTENSIONS.has(extname(entry.name))) continue; + if (paths && !paths.some((value) => path.includes(value))) continue; + const source = await readFile(path, "utf8"); + for (const match of source.matchAll(/(?:class|className)\s*=\s*["'`]([^"'`]+)["'`]/g)) { + for (const value of (match[1] ?? "").split(/\s+/)) if (value) candidates.add(value); + } + } + }; + await visit(root); + return [...candidates]; +} + +export async function compileTailwind(css: string, candidates: string[] = []): Promise { + const tailwindCss = join(dirname(fileURLToPath(import.meta.resolve("tailwindcss/package.json"))), "index.css"); + const compiler = await compile(css, { + base: process.cwd(), + loadStylesheet: async (id) => { + if (!["tailwindcss", "tailwindcss/theme.css", "tailwindcss/utilities.css"].includes(id)) throw new Error(`Unsupported Tailwind stylesheet "${id}"`); + const path = id === "tailwindcss" ? tailwindCss : join(dirname(tailwindCss), id.split("/")[1]!); + return { path, base: process.cwd(), content: await readFile(path, "utf8") }; + }, + }); + return compiler.build(candidates); +} + +export function tailwindIntegration(options: TailwindOptions = {}): Integration { + const route = options.route ?? "/_muddle/tailwind.css"; + const source = options.css ?? '@import "tailwindcss";'; + return { + name: "tailwind", + async setup(context: IntegrationContext) { + const build = () => tailwindCandidates(context.config.root, options.content).then((candidates) => compileTailwind(source, candidates)); + context.loader("tailwind", { + name: "tailwind", + extensions: [".tailwind.css"], + async load(file) { return { data: {}, content: await compileTailwind(file.source, await tailwindCandidates(context.config.root, options.content)) }; }, + }); + context.route("GET", route, async () => new Response(await build(), { headers: { "content-type": "text/css; charset=utf-8", "cache-control": "public, max-age=60" } })); + }, + }; +} diff --git a/modules/tailwind/package.json b/modules/tailwind/package.json new file mode 100644 index 0000000..b3bb253 --- /dev/null +++ b/modules/tailwind/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/tailwind", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "tailwindcss": "^4.3.3" + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d0c69e8 --- /dev/null +++ b/package.json @@ -0,0 +1,61 @@ +{ + "name": "muddle", + "module": "index.ts", + "type": "module", + "private": true, + "scripts": { + "typecheck": "bun check", + "typegen": "bun run contracts/typegen/generator.ts samples/hello-world", + "dev": "bun run samples/hello-world/index.ts", + "react": "bun run samples/react/index.ts", + "react:build": "bun run samples/react/index.ts --prerender", + "build": "bun run samples/hello-world/index.ts --prerender", + "test": "bun test ./tests", + "lint": "bunx eslint . --ignore-pattern node_modules --ignore-pattern ametrine --ignore-pattern .opencode --ignore-pattern dist --ignore-pattern .git --ignore-pattern .cache --ignore-pattern .next --ignore-pattern .turbo --ignore-pattern .vercel --ignore-pattern .vscode --ignore-pattern .idea", + "format": "bun run cli.ts fmt .", + "muddle": "bun run cli.ts" + }, + "bin": { + "muddle": "./cli.ts" + }, + "dependencies": { + "@eslint/js": "^10.0.1", + "lightningcss": "^1.33.0", + "prettier": "^3.9.10", + "sass": "^1.106.1", + "typescript-eslint": "^8.72.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/pg": "^8.15.5", + "playwright": "^1.64.1", + "@muddle/astro": "workspace:*", + "@muddle/database": "workspace:*", + "@muddle/latex": "workspace:*", + "@muddle/mdx": "workspace:*", + "@muddle/tailwind": "workspace:*", + "@muddle/prettier-plugin": "workspace:*", + "@muddle/optimize": "workspace:*", + "@muddle/amezing": "workspace:*", + "@muddle/analysis": "workspace:*" + }, + "peerDependencies": { + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2" + }, + "workspaces": [ + "modules/*" + ], + "exports": { + ".": "./index.ts", + "./contracts": "./contracts/index.ts", + "./contracts/*": "./contracts/*", + "./metadata": "./core/metadata.ts", + "./markdown": "./views/markdown.ts", + "./views": "./views/api.ts", + "./views/integrations": "./views/integrations.ts", + "./stcss": "./views/css/index.ts", + "./sessions": "./core/sessions.ts", + "./formatter": "./core/format-source.ts" + } +} diff --git a/samples/hello-world/.gitignore b/samples/hello-world/.gitignore new file mode 100644 index 0000000..33fd522 --- /dev/null +++ b/samples/hello-world/.gitignore @@ -0,0 +1,3 @@ +dist/ +dist.previous/ +dist.staging/ \ No newline at end of file diff --git a/samples/hello-world/_muddle/contracts/index.ts b/samples/hello-world/_muddle/contracts/index.ts new file mode 100644 index 0000000..249433b --- /dev/null +++ b/samples/hello-world/_muddle/contracts/index.ts @@ -0,0 +1,3 @@ +export * from "./views.ts"; +export * from "./orm/tables.ts"; +export * from "./minify.ts"; diff --git a/samples/hello-world/_muddle/contracts/minify.ts b/samples/hello-world/_muddle/contracts/minify.ts new file mode 100644 index 0000000..1390627 --- /dev/null +++ b/samples/hello-world/_muddle/contracts/minify.ts @@ -0,0 +1,2 @@ +export type Simplify = { [K in keyof T]: T[K] } & {}; +export type Minify = Simplify; diff --git a/samples/hello-world/_muddle/contracts/orm/tables.ts b/samples/hello-world/_muddle/contracts/orm/tables.ts new file mode 100644 index 0000000..0edbd23 --- /dev/null +++ b/samples/hello-world/_muddle/contracts/orm/tables.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + + + +export interface TableRows { + +} + +export type TableName = keyof TableRows; + +/** Row of a single table. */ +export type TableRowOf = TableRows[T]; diff --git a/samples/hello-world/_muddle/contracts/views.ts b/samples/hello-world/_muddle/contracts/views.ts new file mode 100644 index 0000000..898dae0 --- /dev/null +++ b/samples/hello-world/_muddle/contracts/views.ts @@ -0,0 +1,16 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + +import type { Minify } from "./minify.ts"; + +export interface ViewProps { + "modules/app/views/index": { title: string; nav: {label:string,href:string}[]; posts: {id:string,title:string,excerpt:string}[] }; + "modules/app/views/layout": { title: string; nav: {label:string,href:string}[]; currentPath?: string }; + "modules/app/views/post": { title: string; nav: {label:string,href:string}[]; post: {id:string,title:string,excerpt:string}|null }; +} + +/** Path of a view of the project. */ +export type ViewPath = keyof ViewProps; + +/** Props of a single view. */ +export type ViewPropsOf

= Minify; diff --git a/samples/hello-world/config.ts b/samples/hello-world/config.ts new file mode 100644 index 0000000..a619be6 --- /dev/null +++ b/samples/hello-world/config.ts @@ -0,0 +1,4 @@ +import { resolveConfig } from "@muddle/core/config.ts"; +import config from "./ct.config.ts"; + +export const resolved = resolveConfig(config); diff --git a/samples/hello-world/ct.config.ts b/samples/hello-world/ct.config.ts new file mode 100644 index 0000000..2cc3615 --- /dev/null +++ b/samples/hello-world/ct.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from "@muddle/contracts"; +import { stcssIntegration } from "@muddle/views/integrations.ts"; +import { accessibleIntegration, overflowIntegration } from "@muddle/analysis"; + +export default defineConfig({ + root: import.meta.dir, + + views: "modules/app/views", + viewRoots: ["modules/app/views"], + styles: "styles", + + integrations: [ + stcssIntegration({ dir: "styles" }), + accessibleIntegration(), + overflowIntegration(), + ], + + prerender: { + routes: ["/", "/posts/test1", "/posts/test2", "/posts/test3"], + outDir: "dist", + }, + +}); diff --git a/samples/hello-world/index.ts b/samples/hello-world/index.ts new file mode 100644 index 0000000..5e81e8a --- /dev/null +++ b/samples/hello-world/index.ts @@ -0,0 +1,46 @@ +import { join } from "node:path"; +import { serve } from "@muddle/adapters/server-bun.ts"; +import { prerender } from "@muddle/adapters/static.ts"; +import { App } from "@muddle/core/app.ts"; +import { createViews } from "@muddle/views/api.ts"; +import { amezingIntegration } from "@muddle/amezing"; +import { AppModule } from "./modules/app/app.module.ts"; +import { resolved } from "./config.ts"; + +const views = createViews({ + root: resolved.root, + viewsDir: resolved.views, + componentsDir: resolved.components, +}); + +const app = await App.create({ + modules: [AppModule], + views, + config: resolved, + integrations: [...resolved.integrations, amezingIntegration()], +}); + +export async function build(options: { progress?: (event: { current: number; total: number; route: string }) => void } = {}): Promise { + if (!resolved.prerender) return; + const results = await prerender(app, { + routes: resolved.prerender.routes, + outDir: join(resolved.root, resolved.prerender.outDir ?? "dist"), + progress: options.progress + }); + for (const result of results) { + console.log(`muddle: prerendered ${result.route} -> ${result.file}`); + } +} + +if (import.meta.main) { + if (process.argv.includes("--prerender")) { + await build(); + } else { + const server = serve(app, { + port: Number(process.env.PORT ?? 3067), + }); + console.log(`muddle: listening on ${server.url}`); + } +} + +export { app }; diff --git a/samples/hello-world/modules/app/app.controller.ts b/samples/hello-world/modules/app/app.controller.ts new file mode 100644 index 0000000..32e8cdf --- /dev/null +++ b/samples/hello-world/modules/app/app.controller.ts @@ -0,0 +1,53 @@ +import { + Controller, + Param, + Route, + View, + ViewRoot, +} from "@muddle/decorators"; +import type { ViewPropsOf } from "../../_muddle/contracts/views.ts"; + +const NAV = [ + { label: "Home", href: "/" }, +]; + +const POSTS = [ + { id: "test1", title: "Test1", excerpt: "Test1" }, + { id: "test2", title: "Test2", excerpt: "Test2" }, + { id: "test3", title: "Test3", excerpt: "Test3" }, +]; + +@Controller("/") +@ViewRoot("./views") +export class AppController { + @View("index.spug") + index(): ViewPropsOf<"modules/app/views/index"> { + return { + title: "Blog", + nav: NAV, + posts: POSTS, + }; + } + + @Route("/posts/:id") + @View("post.spug") + show(@Param("id") id: string): ViewPropsOf<"modules/app/views/post"> { + const post = POSTS.find((entry) => entry.id === id) ?? null; + if (!post) { + return { + title: "Not found", + nav: NAV, + post: null, + }; + } + return { + title: post.title, + nav: NAV, + post: { + id: post.id, + title: post.title, + excerpt: post.excerpt, + }, + }; + } +} diff --git a/samples/hello-world/modules/app/app.module.ts b/samples/hello-world/modules/app/app.module.ts new file mode 100644 index 0000000..4c5eadc --- /dev/null +++ b/samples/hello-world/modules/app/app.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@muddle/decorators"; +import { AppController } from "./app.controller.ts"; +import { AssetController } from "./asset.controller.ts"; + +@Module({ + controllers: [AppController, AssetController], + providers: [], +}) +export class AppModule {} diff --git a/samples/hello-world/modules/app/asset.controller.ts b/samples/hello-world/modules/app/asset.controller.ts new file mode 100644 index 0000000..cff0878 --- /dev/null +++ b/samples/hello-world/modules/app/asset.controller.ts @@ -0,0 +1,5 @@ +import { Controller, Root } from "@muddle/decorators"; + +@Controller("/assets") +@Root("./public") +export class AssetController {} diff --git a/samples/hello-world/modules/app/public/robots.txt b/samples/hello-world/modules/app/public/robots.txt new file mode 100644 index 0000000..c2a49f4 --- /dev/null +++ b/samples/hello-world/modules/app/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/samples/hello-world/modules/app/views/index.spug b/samples/hello-world/modules/app/views/index.spug new file mode 100644 index 0000000..6af0c48 --- /dev/null +++ b/samples/hello-world/modules/app/views/index.spug @@ -0,0 +1,26 @@ +extends ./layout.spug + +props + title: string + nav: { label: string, href: string }[] + posts: { id: string, title: string, excerpt: string }[] + +block content + section.primary-section@hero() + h1= title + p Test blog + section@posts() + div.card + div.card-title + div.line + span.title Posts + div.line + component amezing-article-list + pages: posts.map((post) => ({ title: post.title, href: `/posts/${post.id}`, description: post.excerpt })) + +$$$ + assertExists("h1") + assertExists(".article-list") + assertAttribute("main", "id", "main-content") + assertAttribute("section.primary-section", "class", "primary-section") +notes diff --git a/samples/hello-world/modules/app/views/layout.spug b/samples/hello-world/modules/app/views/layout.spug new file mode 100644 index 0000000..3263997 --- /dev/null +++ b/samples/hello-world/modules/app/views/layout.spug @@ -0,0 +1,13 @@ +props + title: string + nav: { label: string, href: string }[] + currentPath?: string + +component amezing-base + title: title + siteTitle: title + description: 'A tiny blog built with muddle' + nav: nav.map((entry) => ({ name: entry.label, href: entry.href })) + copyright: '© 2026 muddle' + render content + block content diff --git a/samples/hello-world/modules/app/views/post.spug b/samples/hello-world/modules/app/views/post.spug new file mode 100644 index 0000000..5976681 --- /dev/null +++ b/samples/hello-world/modules/app/views/post.spug @@ -0,0 +1,31 @@ +extends ./layout.spug + +props + title: string + nav: { label: string, href: string }[] + post: { id: string, title: string, excerpt: string } | null + +block content + if post + article@post() + div.card + div.card-title + div.line + span.title= post.title + div.line + component amezing-article + title: post.title + date: 'October 22, 2026' + body: post.excerpt + component amezing-article-nav + previousHref: '/' + previousTitle: 'Back to posts' + else + section.primary-section + h1 Not found + p The post does not exist. + +$$$ + assertExists("article .card-title") + assertText("article h1", "Test1") +notes diff --git a/samples/hello-world/repositories/posts.repository.ts b/samples/hello-world/repositories/posts.repository.ts new file mode 100644 index 0000000..e1107af --- /dev/null +++ b/samples/hello-world/repositories/posts.repository.ts @@ -0,0 +1,3 @@ +import { Repository } from "@muddle/database"; + +export class PostsRepository extends Repository> {} diff --git a/samples/hello-world/styles/app.stcss b/samples/hello-world/styles/app.stcss new file mode 100644 index 0000000..442b783 --- /dev/null +++ b/samples/hello-world/styles/app.stcss @@ -0,0 +1,28 @@ +main#main-content { + max-width: 72rem; + margin: 0 auto; + padding: 0 1.25rem 3rem; +} + +section.primary-section { + max-width: 48rem; + padding: 4rem 0 2rem; + + h1 { + margin: 0.75rem 0 0; + } + + p { + max-width: 36rem; + } + + @predict mobile max-width max-height transform; +} + +section .card { + border-radius: concentric 0.75rem; +} + +.hero-blob { + background-image: freeform-gradient(#7c3aed 10% 50%, #f00 70% 40%); +} diff --git a/samples/hello-world/tsconfig.json b/samples/hello-world/tsconfig.json new file mode 100644 index 0000000..3416094 --- /dev/null +++ b/samples/hello-world/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@muddle/database": ["../../modules/database/index.ts"], + "@muddle/*": ["../../*"], + "$types": ["./_muddle/contracts/*"] + } + } +} diff --git a/samples/react/.gitignore b/samples/react/.gitignore new file mode 100644 index 0000000..785efe4 --- /dev/null +++ b/samples/react/.gitignore @@ -0,0 +1,3 @@ +dist/ +dist.staging/ +dist.previous/ \ No newline at end of file diff --git a/samples/react/README.md b/samples/react/README.md new file mode 100644 index 0000000..810bd2e --- /dev/null +++ b/samples/react/README.md @@ -0,0 +1 @@ +# reactor \ No newline at end of file diff --git a/samples/react/_muddle/contracts/index.ts b/samples/react/_muddle/contracts/index.ts new file mode 100644 index 0000000..249433b --- /dev/null +++ b/samples/react/_muddle/contracts/index.ts @@ -0,0 +1,3 @@ +export * from "./views.ts"; +export * from "./orm/tables.ts"; +export * from "./minify.ts"; diff --git a/samples/react/_muddle/contracts/minify.ts b/samples/react/_muddle/contracts/minify.ts new file mode 100644 index 0000000..1390627 --- /dev/null +++ b/samples/react/_muddle/contracts/minify.ts @@ -0,0 +1,2 @@ +export type Simplify = { [K in keyof T]: T[K] } & {}; +export type Minify = Simplify; diff --git a/samples/react/_muddle/contracts/orm/tables.ts b/samples/react/_muddle/contracts/orm/tables.ts new file mode 100644 index 0000000..0edbd23 --- /dev/null +++ b/samples/react/_muddle/contracts/orm/tables.ts @@ -0,0 +1,13 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + + + +export interface TableRows { + +} + +export type TableName = keyof TableRows; + +/** Row of a single table. */ +export type TableRowOf = TableRows[T]; diff --git a/samples/react/_muddle/contracts/views.ts b/samples/react/_muddle/contracts/views.ts new file mode 100644 index 0000000..6f4f60c --- /dev/null +++ b/samples/react/_muddle/contracts/views.ts @@ -0,0 +1,17 @@ +// AUTO-GENERATED by contracts/typegen/generator.ts, do not edit. +// Run `bun run typegen ` to update this file. + +import type { Minify } from "./minify.ts"; + +export interface ViewProps { + "modules/app/views/feature": { title: string; nav: {id:string,label:string,href:string,active?:boolean}[]; albums: {id:string,name:string,cover:string,tracks:{title:string,duration:string}[]}[]; ribbonStyle?: string; label: string; prose: string|null; features: {label:string,href:string}[]; current?: string }; + "modules/app/views/index": { title: string; nav: {id:string,label:string,href:string,active?:boolean}[]; albums: {id:string,name:string,cover:string,tracks:{title:string,duration:string}[]}[]; ribbonStyle?: string; intro: {id:string,label:string,note:string,tint:string}[]; features: {label:string,href:string}[] }; + "modules/app/views/layout": Record; + "modules/app/views/navbar": { nav: {id:string,label:string,href:string,active?:boolean,detail:string}[]; albums: {id:string,name:string,date:string,cover:string,tracks:{title:string,duration:string}[]}[] }; +} + +/** Path of a view of the project. */ +export type ViewPath = keyof ViewProps; + +/** Props of a single view. */ +export type ViewPropsOf

= Minify; diff --git a/samples/react/config.ts b/samples/react/config.ts new file mode 100644 index 0000000..a619be6 --- /dev/null +++ b/samples/react/config.ts @@ -0,0 +1,4 @@ +import { resolveConfig } from "@muddle/core/config.ts"; +import config from "./ct.config.ts"; + +export const resolved = resolveConfig(config); diff --git a/samples/react/ct.config.ts b/samples/react/ct.config.ts new file mode 100644 index 0000000..8b76c91 --- /dev/null +++ b/samples/react/ct.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "@muddle/contracts"; +import { stcssIntegration } from "@muddle/views/integrations.ts"; + +export default defineConfig({ + root: import.meta.dir, + + views: "modules/app/views", + viewRoots: ["modules/app/views"], + styles: "styles", + + integrations: [stcssIntegration({ dir: "styles" })], + + prerender: { + routes: ["/", "/features/interpolate", "/features/refer", "/features/predict", "/features/route"], + outDir: "dist", + }, +}); diff --git a/samples/react/index.ts b/samples/react/index.ts new file mode 100644 index 0000000..b84b8ec --- /dev/null +++ b/samples/react/index.ts @@ -0,0 +1,51 @@ +import { join } from "node:path"; +import { serve } from "@muddle/adapters/server-bun.ts"; +import { prerender } from "@muddle/adapters/static.ts"; +import { App } from "@muddle/core/app.ts"; +import { createViews } from "@muddle/views/api.ts"; +import { AppModule } from "./modules/app/app.module.ts"; +import { resolved } from "./config.ts"; + +const views = createViews({ + root: resolved.root, + viewsDir: resolved.views, +}); + +const app = await App.create({ + modules: [AppModule], + views, + config: resolved, + integrations: resolved.integrations, +}); + +export async function build(): Promise { + if (!resolved.prerender) return; + const results = await prerender(app, { + routes: resolved.prerender.routes, + outDir: join(resolved.root, resolved.prerender.outDir ?? "dist"), + predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] }, + progress: (progress) => { + console.log( + `[...] prerendering ${progress.route} (${progress.current}/${progress.total})`, + ); + }, + }); + for (const result of results) { + console.log( + `[OK ] prerendered ${result.route} @ ${result.file} (${result.status})`, + ); + } +} + +if (import.meta.main) { + if (process.argv.includes("--prerender")) { + await build(); + } else { + const server = serve(app, { + port: Number(process.env.PORT ?? 3069), + }); + console.log(`muddle: listening on ${server.url}`); + } +} + +export { app }; diff --git a/samples/react/modules/app/albumStyle.ts b/samples/react/modules/app/albumStyle.ts new file mode 100644 index 0000000..788e27e --- /dev/null +++ b/samples/react/modules/app/albumStyle.ts @@ -0,0 +1,27 @@ +/** + * Server-generated stylesheet for the discography navbar entries. Each album + * gets a `$refer` rule that moves the ribbon under its navbar item plus a + * cross-fade rule that reveals its pane. + */ +import { compileCss } from "../../../../views/css/index.ts"; +import type { Album } from "./musicbrainz.ts"; + +export function albumRibbonCss(albums: Album[]): string { + if (albums.length === 0) return ""; + const ids = albums.map((entry) => entry.id); + const source = `@each $id in ${ids.join(" ")} { + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) .navbar__ribbon, + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) .navbar__ribbon { + --ribbon-x: calc($refer[left](@navitem#{$id}) + $refer[width](@navitem#{$id}) / 2 - $refer[left](@navbar) - ($refer[width](@pane#{$id}) + 2.4rem + 2px) / 2); + --ribbon-w: calc($refer[width](@pane#{$id}) + 2.4rem + 2px); + --ribbon-h: calc($refer[height](@pane#{$id}) + 1.6rem + 2px); + } + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) [data-muddle-anchor="pane#{$id}"], + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) [data-muddle-anchor="pane#{$id}"] { + opacity: 1; + visibility: visible; + animation: fadein var(--dur) var(--ease); + } +}`; + return compileCss(source, { measured: [] }); +} diff --git a/samples/react/modules/app/app.controller.ts b/samples/react/modules/app/app.controller.ts new file mode 100644 index 0000000..2a92a16 --- /dev/null +++ b/samples/react/modules/app/app.controller.ts @@ -0,0 +1,64 @@ +import { + Controller, + Param, + RenderAhead, + Route, + View, + ViewRoot, +} from "@muddle/decorators"; +import { FEATURES, INTRO, navFor } from "./registry.ts"; +import { albumRibbonCss } from "./albumStyle.ts"; +import { discography } from "./musicbrainz.ts"; + +import type { ViewPropsOf } from "../../_muddle/contracts/views.ts"; + +const EMPTY = { + title: "Not found", + nav: navFor(), + albums: [], + ribbonStyle: "", + label: "Not found", + prose: null, + features: [], + current: undefined, +}; + +@Controller("/") +@ViewRoot("./views") +export class AppController { + @View("index.spug") + async index(): Promise> { + const albums = await discography(); + return { + title: "muddle", + nav: navFor(), + albums, + ribbonStyle: albumRibbonCss(albums), + intro: INTRO, + features: [], + }; + } + + @Route("/features/:slug") + @RenderAhead(FEATURES.map((feature) => feature.href)) + @View("feature.spug") + async show( + @Param("slug") slug: string, + ): Promise> { + const feature = FEATURES.find((entry) => entry.slug === slug); + const albums = await discography(); + if (!feature) { + return { ...EMPTY, nav: navFor() }; + } + return { + title: feature.label, + nav: navFor(feature.slug), + albums, + ribbonStyle: albumRibbonCss(albums), + label: feature.label, + prose: await feature.prose(), + features: [], + current: feature.href, + }; + } +} diff --git a/samples/react/modules/app/app.module.ts b/samples/react/modules/app/app.module.ts new file mode 100644 index 0000000..fbf6d35 --- /dev/null +++ b/samples/react/modules/app/app.module.ts @@ -0,0 +1,8 @@ +import { Module } from "@muddle/decorators"; +import { AppController } from "./app.controller.ts"; + +@Module({ + controllers: [AppController], + providers: [], +}) +export class AppModule {} diff --git a/samples/react/modules/app/musicbrainz.ts b/samples/react/modules/app/musicbrainz.ts new file mode 100644 index 0000000..8bc391e --- /dev/null +++ b/samples/react/modules/app/musicbrainz.ts @@ -0,0 +1,184 @@ +/** + * Porter Robinson's discography, loaded from MusicBrainz at server-render + * time with a small in-memory cache so the sample stays request-fast. + */ + +const BASE = "https://musicbrainz.org/ws/2"; +const ARTIST = "4ae36ade-1798-48c4-b06b-cc68b7d3d83f"; +const USER_AGENT = "muddle-react-sample/0.1 (discography demo; devnull@crit.rip)"; +const TTL = 1000 * 60 * 30; + +export interface Track { + title: string; + duration: string; +} + +export interface Album { + id: string; + name: string; + date: string; + cover: string; + tracks: Track[]; +} + +interface ReleaseGroup { + id: string; + title: string; + "first-release-date": string; + "primary-type": string; + "secondary-types"?: string[]; +} + +interface MbRelease { + media?: { tracks?: { position: number; title: string; length?: number }[] }[]; +} + +let cache: { albums: Album[]; at: number } | undefined; + +function formatDuration(length?: number): string { + if (!length) return ""; + const seconds = Math.round(length / 1000); + const parts = [Math.floor(seconds / 60), seconds % 60]; + return `${parts[0]}:${String(parts[1]).padStart(2, "0")}`; +} + +async function mbJson(path: string, attempt = 0): Promise { + const response = await fetch(`${BASE}${path}`, { + headers: { "user-agent": USER_AGENT, accept: "application/json" }, + }); + if (!response.ok) { + if ((response.status === 503 || response.status === 429) && attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 450 * (attempt + 1))); + return mbJson(path, attempt + 1); + } + throw new Error(`musicbrainz: ${response.status} ${path}`); + } + return await response.json() as T; +} + +function isStudioAlbum(entry: ReleaseGroup): boolean { + const title = entry.title.toLowerCase(); + return !/(live|remix|commentary|edition|remastered|deluxe|second sky|secret sky|matt dipirro)/.test(title); +} + +async function tracksFor(groupId: string): Promise { + const releases = await mbJson<{ releases?: { id: string; country?: string }[] }>( + `/release-group/${groupId}?inc=releases&fmt=json`, + ); + // prefer a digital or worldwide edition: sort by shortest country code chain + const candidate = (releases.releases ?? []).slice() + .sort((a, b) => (a.country?.length ?? 9) - (b.country?.length ?? 9))[0]; + if (!candidate) return []; + const release = await mbJson(`/release/${candidate.id}?inc=recordings+media&fmt=json`); + const media = (release.media ?? []).find((medium) => (medium.tracks?.length ?? 0) > 0) + ?? release.media?.[0]; + return (media?.tracks ?? []).map((track) => ({ + title: track.title, + duration: formatDuration(track.length), + })); +} + +export async function discography(): Promise { + if (cache && Date.now() - cache.at < TTL) return cache.albums; + try { + const data = await mbJson<{ "release-groups"?: ReleaseGroup[] }>( + `/artist/${ARTIST}?inc=release-groups&fmt=json`, + ); + const groups = (data["release-groups"] ?? []) + .filter((entry) => entry["primary-type"] === "Album" + && (entry["secondary-types"]?.length ?? 0) === 0 + && isStudioAlbum(entry)) + .sort((a, b) => a["first-release-date"].localeCompare(b["first-release-date"])) + .slice(0, 4); + const albums: Album[] = []; + for (const [index, group] of groups.entries()) { + if (index > 0) await new Promise((resolve) => setTimeout(resolve, 320)); + let tracks: Track[] = []; + try { + tracks = await tracksFor(group.id); + } catch { + if (index < 2) throw new Error("musicbrainz: tracklist fetch failed"); + continue; + } + albums.push({ + id: group.id, + name: group.title, + date: group["first-release-date"], + cover: `https://coverartarchive.org/release-group/${group.id}/front-250`, + tracks, + }); + } + if (albums.length > 0) { + cache = { albums, at: Date.now() }; + return albums; + } + return cache?.albums ?? offlineDiscography(); + } catch { + return cache?.albums ?? offlineDiscography(); + } +} + +/** Studio albums used when MusicBrainz is unreachable or throttled. */ +function offlineDiscography(): Album[] { + return [ + { + id: "6df00711-624a-4960-9f15-39baf9332cbb", + name: "Worlds", + date: "2014-08-08", + cover: "https://coverartarchive.org/release-group/6df00711-624a-4960-9f15-39baf9332cbb/front-250", + tracks: [ + { title: "Divinity", duration: "6:09" }, + { title: "Sad Machine", duration: "5:54" }, + { title: "Years of War", duration: "3:56" }, + { title: "Flicker", duration: "4:39" }, + { title: "Fresh Static Snow", duration: "5:59" }, + { title: "Polygon Dust", duration: "3:43" }, + { title: "Hear the Bells", duration: "4:06" }, + { title: "Natural Light", duration: "3:16" }, + { title: "Lionhearted", duration: "3:51" }, + { title: "Sea of Voices", duration: "5:42" }, + { title: "Goodbye to a World", duration: "4:16" }, + ], + }, + { + id: "e2cc165d-b935-4e7c-8728-77cf01ab21ed", + name: "Nurture", + date: "2021-04-23", + cover: "https://coverartarchive.org/release-group/e2cc165d-b935-4e7c-8728-77cf01ab21ed/front-250", + tracks: [ + { title: "Lifelike", duration: "0:43" }, + { title: "Look at the Sky", duration: "4:02" }, + { title: "Get Your Wish", duration: "3:09" }, + { title: "Something Comforting", duration: "4:19" }, + { title: "Blossom", duration: "1:48" }, + { title: "Musician", duration: "3:09" }, + { title: "Shelter (nurture mix)", duration: "4:13" }, + { title: "Wind Tempest", duration: "3:45" }, + { title: "Paper Rings", duration: "b:58" }, + { title: "Learning Objects", duration: "2:30" }, + { title: "Time on My Hands (Until I Get You)", duration: "−:53" }, + { title: "Mirror", duration: "3:36" }, + { title: "unfold", duration: "5:33" }, + { title: "Everything Goes On", duration: "4:12" }, + ], + }, + { + id: "87964290-66b9-4fd9-bea3-1bfe9de92862", + name: "SMILE! :D", + date: "2024-07-26", + cover: "https://coverartarchive.org/release-group/87964290-66b9-4fd9-bea3-1bfe9de92862/front-250", + tracks: [ + { title: "Knock Yourself Out XD", duration: "3:13" }, + { title: "Cheerleader", duration: "3:01" }, + { title: "Everything Is Changing", duration: "3:20" }, + { title: "Kitsune Maison Sixtyone", duration: "2:48" }, + { title: "Easier to Love You", duration: "3:22" }, + { title: "Russian Roulette", duration: "2:54" }, + { title: "Mona Lisa Smile", duration: "3:22" }, + { title: "Do You Want to Be a Lonely One?", duration: "3:04" }, + { title: "Everything Goes On (smile edit)", duration: "4:16" }, + { title: "The Man Who Sold the World", duration: "3:10" }, + ], + }, + ]; +} diff --git a/samples/react/modules/app/registry.ts b/samples/react/modules/app/registry.ts new file mode 100644 index 0000000..f84150d --- /dev/null +++ b/samples/react/modules/app/registry.ts @@ -0,0 +1,78 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +export interface NavItem { + id: string; + label: string; + href: string; + active?: boolean; + detail: string; +} + +export interface Feature { + slug: string; + label: string; + href: string; + detail: string; + prose: () => Promise; +} + +const ROOT = import.meta.dir; + +const DEFINITIONS: Feature[] = [ +]; + +export const FEATURES: Feature[] = DEFINITIONS.map((feature) => ({ ...feature, prose: (prose = feature.prose) => prose() })); + +export const NAV: NavItem[] = [ + { id: "home", label: "Home", href: "/", detail: "Porter discography powered by muddle" }, + ...DEFINITIONS.map((feature) => ({ + id: feature.slug, + label: feature.label, + href: feature.href, + detail: feature.detail, + active: false, + })), +]; + +export function navFor(slug?: string): NavItem[] { + return NAV.map((item) => ({ + ...item, + active: slug ? item.id === slug : item.id === "home", + })); +} + +export interface IntroTopic { + id: string; + label: string; + note: string; + tint: string; +} + +export const INTRO: IntroTopic[] = [ + { + id: "scaffold", + label: "Scaffold", + note: "Every variable is compiled ahead of time so the page ships with zero runtime javascript.", + tint: "violet", + }, + { + id: "relations", + label: "Relations", + note: "Anchors like @introtopic record geometry and computed styles so stylesheets can reason about other elements.", + tint: "rose", + }, + { + id: "predict", + label: "Prediction", + note: "Prerender measures this panel and ships the numbers, so the first click already animates smoothly.", + tint: "sky", + }, + { + id: "momentum", + label: "Momentum", + note: "The whole greeting is server-rendered and hydrates instantly thanks to pre-computed wait.", + tint: "amber", + }, +]; + diff --git a/samples/react/modules/app/storage/interpolate.txt b/samples/react/modules/app/storage/interpolate.txt new file mode 100644 index 0000000..b41e1c5 --- /dev/null +++ b/samples/react/modules/app/storage/interpolate.txt @@ -0,0 +1 @@ +Interpolation \ No newline at end of file diff --git a/samples/react/modules/app/storage/predict.txt b/samples/react/modules/app/storage/predict.txt new file mode 100644 index 0000000..231d92d --- /dev/null +++ b/samples/react/modules/app/storage/predict.txt @@ -0,0 +1 @@ +Predicts web content \ No newline at end of file diff --git a/samples/react/modules/app/storage/refer.txt b/samples/react/modules/app/storage/refer.txt new file mode 100644 index 0000000..06de048 --- /dev/null +++ b/samples/react/modules/app/storage/refer.txt @@ -0,0 +1 @@ +Refers to other elements \ No newline at end of file diff --git a/samples/react/modules/app/storage/route.txt b/samples/react/modules/app/storage/route.txt new file mode 100644 index 0000000..15de57e --- /dev/null +++ b/samples/react/modules/app/storage/route.txt @@ -0,0 +1 @@ +Routes statically and dynamically \ No newline at end of file diff --git a/samples/react/modules/app/views/feature.spug b/samples/react/modules/app/views/feature.spug new file mode 100644 index 0000000..d924165 --- /dev/null +++ b/samples/react/modules/app/views/feature.spug @@ -0,0 +1,21 @@ +extends ./layout.spug + +props + title: string + nav: { id: string, label: string, href: string, active?: boolean }[] + albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[] + ribbonStyle?: string + label: string + prose: string | null + features: { label: string, href: string }[] + current?: string + +block content + section.demo-feature + h1= label + if prose + p.prose= prose + + nav(aria-label="Feature routes") + each feature in features + a(href=feature.href aria-current=feature.href === current ? 'page' : '')= feature.label diff --git a/samples/react/modules/app/views/index.spug b/samples/react/modules/app/views/index.spug new file mode 100644 index 0000000..ce7615f --- /dev/null +++ b/samples/react/modules/app/views/index.spug @@ -0,0 +1,34 @@ +extends ./layout.spug + +props + title: string + nav: { id: string, label: string, href: string, active?: boolean }[] + albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[] + ribbonStyle?: string + intro: { id: string, label: string, note: string, tint: string }[] + features: { label: string, href: string }[] + +block content + section.demo-hero + h1= title + p + + section.demo-intro(aria-label="Intro scene") + div.demo-intro__stage@introstage() + ul.demo-intro__topics(role="tablist") + each step, index in intro + li + input.demo-intro__toggle(id=('intro-step-' + step.id) type="radio" name="intro-step" checked=index === 0) + label.demo-intro__topic@introtopic(step.id)(for=('intro-step-' + step.id) role="tab" data-tint=step.tint) + = step.label + div.demo-intro__panel@intropanel() + each step in intro + p.demo-intro__note@intropane(step.id)() + = step.note + + section.demo-meter.demo-meter--one + i + section.demo-meter.demo-meter--two + i + section.demo-meter.demo-meter--three + i diff --git a/samples/react/modules/app/views/layout.spug b/samples/react/modules/app/views/layout.spug new file mode 100644 index 0000000..d8a62b5 --- /dev/null +++ b/samples/react/modules/app/views/layout.spug @@ -0,0 +1,15 @@ +doctype html +html(lang="en") + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + title= title + link(rel="stylesheet" href="/_muddle/styles.css") + style != ribbonStyle + body + component navbar + nav: nav + albums: albums + + main.page + block content diff --git a/samples/react/modules/app/views/navbar.spug b/samples/react/modules/app/views/navbar.spug new file mode 100644 index 0000000..f81dc12 --- /dev/null +++ b/samples/react/modules/app/views/navbar.spug @@ -0,0 +1,34 @@ +props + nav: { id: string, label: string, href: string, active?: boolean, detail: string }[] + albums: { id: string, name: string, cover: string, tracks: { title: string, duration: string }[] }[] + +nav.navbar() + div.navbar__row@navbar() + a.navbar__brand(href="/") reactor + span.navbar__pill(aria-hidden="true") + ul.navbar__items(role="list") + each entry in nav + li + a.navbar__item@navitem(entry.id)(href=entry.href aria-current=entry.active ? 'page' : '') + = entry.label + each album in albums + li + a.navbar__item@navitem(album.id)(href="/" title=album.name) + = album.name + div.navbar__ribbon(aria-live="polite") + each entry in nav + p.navbar__pane@pane(entry.id)() + = entry.detail + each album in albums + div.navbar__pane.navbar__pane--album@pane(album.id)() + div.navbar__album-header + figure.navbar__pane-cover + img(src=album.cover alt=album.name loading="lazy" width="56" height="56") + div + p.navbar__pane-head= album.name + time.navbar__album-date(datetime=album.date)= album.date + ol.navbar__tracks + each track in album.tracks + li + span= track.title + span.navbar__track-duration= track.duration diff --git a/samples/react/styles/app.stcss b/samples/react/styles/app.stcss new file mode 100644 index 0000000..03612d1 --- /dev/null +++ b/samples/react/styles/app.stcss @@ -0,0 +1,717 @@ +$scale: 3; +$ease: cubic-bezier(0.32, 0.72, 0.22, 1); + +:root { + --bg: #fff; + --ink: #17130e; + --ink-soft: #71695c; + --line: #e4ded3; + --card: #ffffff; + --accent: #7c3aed; + --ease: $ease; + --dur: 0.32s; + --radius: 0.75rem; +} + +.navbar { + position: sticky; + top: 0; + z-index: 20; + isolation: isolate; + background: var(--card); + border-bottom: 1px solid var(--line); + + &::after { + content: ""; + position: absolute; + top: 100%; + left: 0; + width: 100%; + height: 0.5rem; + } +} + +.navbar__row { + position: relative; + max-width: 72rem; + margin: 0 auto; + display: flex; + align-items: center; + gap: 0.9rem; + padding: 0.55rem 1.25rem 0.45rem; +} + +.navbar__brand { + color: var(--ink); + text-decoration: none; + font-weight: 650; + letter-spacing: -0.02em; + font-size: 0.95rem; +} + +.navbar { + --pill-x: $refer[left](@navitemhome); + --pill-y: $refer[top](@navitemhome); + --pill-w: $refer[width](@navitemhome); + --pill-h: $refer[height](@navitemhome); +} + +@each $item in naviteminterpolate navitemrefer navitempredict navitemroute { + .navbar:has([data-muddle-anchor="#{$item}"][aria-current="page"]) { + --pill-x: $refer[left](@#{$item}); + --pill-y: $refer[top](@#{$item}); + --pill-w: $refer[width](@#{$item}); + --pill-h: $refer[height](@#{$item}); + } +} + +.navbar__pill { + position: absolute; + left: calc(var(--pill-x) - $refer[left](@navbar)); + top: calc(var(--pill-y) - $refer[top](@navbar)); + width: var(--pill-w); + height: var(--pill-h); + box-sizing: border-box; + border: 1px solid var(--accent); + border-radius: 999px; + pointer-events: none; + transition: left var(--dur) $ease, top var(--dur) $ease, width var(--dur) $ease, height var(--dur) $ease; +} + +.navbar__items { + display: flex; + gap: 0.15rem; + margin-left: auto; + list-style: none; + scroll-behavior: smooth; + scroll-snap-type: x proximity; + overscroll-behavior-inline: contain; +} + +.navbar__item { + display: block; + scroll-snap-align: center; + padding: 0.3rem 0.62rem; + border-radius: 0.55rem; + color: var(--ink-soft); + text-decoration: none; + font-size: 0.82rem; + transition: color var(--dur) $ease; + + &[aria-current="page"] { + color: var(--ink); + font-weight: 620; + } + + &:hover { + color: var(--ink); + } +} + +:root { + --muddle-predict-duration: 300ms; + --muddle-predict-easing: $ease; +} + +.navbar { + --ribbon-x: calc($refer[left](@navitemhome) + $refer[width](@navitemhome) / 2 - $refer[left](@navbar) - ($refer[width](@panehome) + 1.6rem + 2px) / 2); + --ribbon-w: calc($refer[width](@panehome) + 1.6rem + 2px); + --ribbon-h: calc($refer[height](@panehome) + 1.24rem + 2px); + --pointer-x: calc($refer[left](@navitemhome) + $refer[width](@navitemhome) / 2 - $refer[left](@navbar) - var(--ribbon-x)); +} + +@each $id in home interpolate refer predict route { + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) .navbar__ribbon, + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) .navbar__ribbon { + --ribbon-x: calc($refer[left](@navitem#{$id}) + $refer[width](@navitem#{$id}) / 2 - $refer[left](@navbar) - ($refer[width](@pane#{$id}) + 1.6rem + 2px) / 2); + --ribbon-w: calc($refer[width](@pane#{$id}) + 1.6rem + 2px); + --ribbon-h: calc($refer[height](@pane#{$id}) + 1.24rem + 2px); + --pointer-x: calc($refer[left](@navitem#{$id}) + $refer[width](@navitem#{$id}) / 2 - $refer[left](@navbar) - var(--ribbon-x)); + } +} + +.navbar__ribbon { + position: fixed; + top: calc($refer[top](@navbar) + $refer[height](@navbar)); + left: calc($refer[left](@navbar) + var(--ribbon-x)); + width: var(--ribbon-w); + height: var(--ribbon-h); + max-width: min(24rem, calc(100vw - 3rem)); + box-sizing: border-box; + overflow: clip; + clip-path: inset(-10px 0 0 round 0.6rem); + border: 1px solid var(--line); + border-radius: 0.6rem; + background: var(--card); + box-shadow: 0 14px 30px -20px rgba(23, 19, 14, 0.45); + opacity: 0; + visibility: visible; + transform: translateY(6px); + pointer-events: auto; + z-index: 30; + @predict left top width height opacity transform visibility; + @measure; + + &::before, + &::after { + content: ""; + position: absolute; + top: -6px; + left: calc(var(--pointer-x) - 6px); + width: 12px; + height: 12px; + transform: rotate(45deg); + pointer-events: none; + @predict transform; + } + + &::before { + z-index: 1; + background: var(--card); + border-left: 1px solid var(--line); + border-top: 1px solid var(--line); + } + + &::after { + z-index: 2; + width: 8px; + height: 8px; + top: -5px; + left: calc(var(--pointer-x) - 4px); + background: var(--card); + } +} + + +.navbar:has(.navbar__item:hover) .navbar__ribbon, +.navbar:has(.navbar__ribbon:hover) .navbar__ribbon, +.navbar:has(.navbar__items:focus-within) .navbar__ribbon { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.navbar__pane { + position: absolute; + top: 0.62rem; + left: 0.8rem; + width: max-content; + max-width: 24rem; + margin: 0; + font-size: 0.8rem; + line-height: 1.5; + color: var(--ink-soft); + opacity: 0; + visibility: hidden; + transition: opacity var(--dur) $ease, visibility var(--dur) step-end; +} + +@each $id in home interpolate refer predict route { + .navbar:has([data-muddle-anchor="navitem#{$id}"]:hover) [data-muddle-anchor="pane#{$id}"], + .navbar:has([data-muddle-anchor="navitem#{$id}"]:focus-visible) [data-muddle-anchor="pane#{$id}"] { + opacity: 1; + visibility: visible; + animation: fadein var(--dur) $ease; + } +} + +@keyframes fadein { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.navbar__pane-cover { + margin: 0; + + img { + width: 3rem; + height: 3rem; + border-radius: 0.45rem; + border: 1px solid var(--line); + display: block; + background: var(--bg); + } +} + +.navbar__pane--album { + width: min(19rem, calc(100vw - 4rem)); + max-height: min(20rem, calc(100vh - 6rem)); + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-width: thin; + scrollbar-color: var(--line) transparent; + padding-right: 0.2rem; +} + +.navbar__album-header { + display: grid; + grid-template-columns: 3rem minmax(0, 1fr); + gap: 0.7rem; + align-items: center; +} + +.navbar__pane-head { + margin: 0; + font-size: 0.8rem; + font-weight: 600; + color: var(--ink); +} + +.navbar__album-date { + display: block; + margin-top: 0.18rem; + font-size: 0.7rem; + color: var(--ink-soft); + font-variant-numeric: tabular-nums; +} + +.navbar__tracks { + margin: 0.7rem 0 0; + padding: 0; + list-style: none; + font-size: 0.74rem; + line-height: 1.6; + color: var(--ink-soft); + counter-reset: track; + + li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.8rem; + align-items: baseline; + counter-increment: track; + border-top: 1px solid color-mix(in srgb, var(--line) 72%, transparent); + padding: 0.28rem 0; + + span:first-child::before { + content: counter(track, decimal-leading-zero); + display: inline-block; + width: 1.65rem; + color: color-mix(in srgb, var(--ink-soft) 62%, transparent); + font-variant-numeric: tabular-nums; + } + } +} + +.navbar__track-duration { + margin-left: 0; + color: color-mix(in srgb, var(--ink-soft) 70%, transparent); + font-variant-numeric: tabular-nums; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; +} + +a { + color: var(--accent); +} + +.page { + max-width: 72rem; + margin: 0 auto; + padding: 2.2rem 1.25rem 3rem; +} + +.card { + background: var(--card); + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 1.15rem 1.25rem; +} + +.demo-hero { + max-width: 44rem; + margin: 0.8rem 0 2rem; + + h1 { + margin: 0; + font-size: clamp(1.9rem, 4vw, 2.9rem); + letter-spacing: -0.03em; + } + + p { + margin: 0.85rem 0 0; + max-width: 34rem; + color: var(--ink-soft); + line-height: 1.65; + + kbd { + font-size: 0.72rem; + background: var(--card); + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 0.3rem; + padding: 0.05rem 0.3rem; + } + } +} + +.demo-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr)); + gap: 0.8rem; + + a { + display: block; + height: 100%; + text-decoration: none; + color: inherit; + } + + .card { + height: 100%; + } + + strong { + display: block; + margin-bottom: 0.3rem; + font-size: 0.9rem; + } + + span { + font-size: 0.8rem; + color: var(--ink-soft); + line-height: 1.5; + } +} + +.demo-aside { + margin-top: 1rem; + font-size: 0.8rem; + color: var(--ink-soft); + + code { + background: var(--card); + border: 1px solid var(--line); + border-radius: 0.3rem; + padding: 0.05rem 0.3rem; + } +} + +.demo-feature { + max-width: 44rem; + + h1 { + margin: 0.6rem 0 0; + font-size: clamp(1.7rem, 3.4vw, 2.4rem); + letter-spacing: -0.02em; + } + + nav { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-top: 1.4rem; + + a { + font-size: 0.8rem; + text-decoration: none; + border: 1px solid var(--line); + border-radius: 999px; + background: var(--card); + color: var(--ink-soft); + padding: 0.2rem 0.65rem; + transition: color var(--dur) $ease, border-color var(--dur) $ease; + + &:hover { + color: var(--ink); + border-color: var(--ink); + } + + &[aria-current="page"] { + color: var(--card); + border-color: var(--ink); + background: var(--ink); + } + } + } + + .demo-grid { + margin-top: 2rem; + } +} + +.demo-meter { + display: flex; + flex-direction: column; + gap: 0.45rem; + margin-top: 1.2rem; + + i { + display: block; + height: 0.55rem; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 18%, var(--card)); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--line)); + } +} + +.demo-meter--one i { + width: #{($scale * 2) * 2}rem; +} + +.demo-meter--two i { + width: #{$scale * 2.5}rem; +} + +.demo-meter--three i { + width: calc($scale * 3rem); +} + +.demo-intro { + margin-top: 1.6rem; + max-width: 44rem; +} + +.demo-intro__stage { + --intro-tint: var(--accent); + --intro-x: calc($refer[left](@introtopicscaffold) + $refer[width](@introtopicscaffold) / 2 - $refer[left](@introstage)); + --intro-w: calc($refer[width](@intropanescaffold) + 2rem); + --intro-h: calc($refer[height](@intropanescaffold) + 1.4rem); + position: relative; + display: flex; + flex-direction: column; + gap: 0.9rem; + padding: 0.9rem 1rem 1.5rem; + border: 1px solid var(--line); + border-radius: 1rem; + background: + linear-gradient(to bottom, color-mix(in srgb, var(--intro-tint) 16%, transparent) 0%, transparent 60%), + var(--card); +} + +.demo-intro__topics { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + list-style: none; + margin: 0; + padding: 0; +} + +.demo-intro__topic { + display: inline-flex; + font: inherit; + font-size: 0.8rem; + padding: 0.28rem 0.7rem; + border: 1px solid var(--line); + border-radius: 999px; + background: transparent; + color: var(--ink-soft); + cursor: pointer; + transition: color var(--dur) $ease, border-color var(--dur) $ease, background-color var(--dur) $ease; + + &:hover { + color: var(--ink); + border-color: var(--ink); + } + + .demo-intro__toggle:checked + & { + color: var(--card); + background: var(--ink); + border-color: var(--ink); + } + + &:focus-visible { + outline: 2px solid var(--intro-tint); + outline-offset: 1px; + } +} + +.demo-intro__toggle { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.demo-intro__panel { + position: absolute; + left: max(0.75rem, calc(var(--intro-x) - var(--intro-w) / 2)); + top: calc($refer[top](@introtopicscaffold) - $refer[top](@introstage) + $refer[height](@introtopicscaffold) + 0.75rem); + width: var(--intro-w); + height: var(--intro-h); + box-sizing: border-box; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--intro-tint) 45%, var(--line)); + border-radius: 0.7rem; + background: var(--card); + box-shadow: 0 12px 26px -18px rgba(23, 19, 14, 0.5); + opacity: 0; + visibility: hidden; + transform: translateY(5px); + pointer-events: none; + @predict left top width height opacity transform visibility; + @measure; +} + +.demo-intro__note { + position: absolute; + left: 1rem; + top: 0.7rem; + width: max-content; + max-width: 24rem; + margin: 0; + font-size: 0.78rem; + line-height: 1.55; + color: var(--ink-soft); + opacity: 0; + visibility: hidden; + @measure; +} + +.demo-intro__stage:has(.demo-intro__topics:focus-within) .demo-intro__panel { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +.demo-intro__stage:has([data-tint="violet"]:hover), +.demo-intro__stage:has(#intro-step-scaffold:checked) { + --intro-tint: #7c3aed; +} + +.demo-intro__stage:has([data-tint="rose"]:hover), +.demo-intro__stage:has(#intro-step-relations:checked) { + --intro-tint: #e11d48; +} + +.demo-intro__stage:has([data-tint="sky"]:hover), +.demo-intro__stage:has(#intro-step-predict:checked) { + --intro-tint: #0284c7; +} + +.demo-intro__stage:has([data-tint="amber"]:hover), +.demo-intro__stage:has(#intro-step-momentum:checked) { + --intro-tint: #d97706; +} + +@each $id in scaffold relations predict momentum { + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:hover) .demo-intro__panel, + .demo-intro__stage:has(#intro-step-#{$id}:checked) .demo-intro__panel { + --intro-x: calc($refer[left](@introtopic#{$id}) + $refer[width](@introtopic#{$id}) / 2 - $refer[left](@introstage)); + --intro-w: calc($refer[width](@intropane#{$id}) + 2rem); + --intro-h: calc($refer[height](@intropane#{$id}) + 1.4rem); + } +} + +@each $id in scaffold relations predict momentum { + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:hover) [data-muddle-anchor="intropane#{$id}"], + .demo-intro__stage:has(#intro-step-#{$id}:checked) [data-muddle-anchor="intropane#{$id}"], + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:focus-visible) [data-muddle-anchor="intropane#{$id}"] { + opacity: 1; + visibility: visible; + } +} + +@each $id in scaffold relations predict momentum { + .demo-intro__stage:has(.demo-intro__topic:hover):has(#intro-step-#{$id}:checked) [data-muddle-anchor="intropane#{$id}"] { + opacity: 0; + visibility: hidden; + } + + .demo-intro__stage:has([data-muddle-anchor="introtopic#{$id}"]:hover) [data-muddle-anchor="intropane#{$id}"] { + opacity: 1; + visibility: visible; + } +} + +.demo-intro__stage:has(.demo-intro__toggle:checked) .demo-intro__panel { + opacity: 1; + visibility: visible; + transform: translateY(0); + pointer-events: auto; +} + +.prose { + max-width: 40rem; + line-height: 1.7; + font-size: 0.95rem; + margin-top: 1.1rem; +} + +.demo-footer { + max-width: 72rem; + margin: 0 auto; + padding: 1rem 1.25rem 2rem; + border-top: 1px solid var(--line); + font-size: 0.75rem; + color: var(--ink-soft); +} + +@media (max-width: 48rem) { + .navbar__row { + flex-wrap: wrap; + padding: 0.5rem 1rem 0.35rem; + } + + .navbar__brand { + margin-right: auto; + } + + .navbar__items { + margin-left: 0; + order: 3; + width: 100%; + gap: 0.05rem; + overflow-x: auto; + scrollbar-width: none; + padding-bottom: 0.25rem; + } + + .navbar__item { + font-size: 0.72rem; + padding: 0.25rem 0.5rem; + white-space: nowrap; + } + + .navbar__ribbon { + max-width: calc(100vw - 1.5rem); + } + + .page { + padding: 1.4rem 1rem 2.2rem; + } + + .demo-hero h1 { + font-size: clamp(1.6rem, 7vw, 2.2rem); + } + + .demo-intro__stage { + padding: 0.8rem 0.8rem 1.2rem; + } + + .demo-intro__note { + max-width: calc(100vw - 5rem); + } + + .demo-intro__panel { + max-width: calc(100vw - 2rem); + } + + .demo-meter i { + height: 0.5rem; + } + + .navbar__pane-cover img { + width: 2.4rem; + height: 2.4rem; + } + + .navbar__pane-head { + font-size: 0.72rem; + } + + .navbar__tracks { + font-size: 0.68rem; + } +} diff --git a/samples/react/tsconfig.json b/samples/react/tsconfig.json new file mode 100644 index 0000000..f6a057c --- /dev/null +++ b/samples/react/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@muddle/*": ["../../*"], + "$types": ["./_muddle/contracts/*"] + } + } +} diff --git a/samples/shadcn-porter/config.ts b/samples/shadcn-porter/config.ts new file mode 100644 index 0000000..8b3264a --- /dev/null +++ b/samples/shadcn-porter/config.ts @@ -0,0 +1,3 @@ +import { resolveConfig } from "@muddle/core/config"; +import config from "./ct.config.ts"; +export const resolved = resolveConfig(config); diff --git a/samples/shadcn-porter/ct.config.ts b/samples/shadcn-porter/ct.config.ts new file mode 100644 index 0000000..bc42f30 --- /dev/null +++ b/samples/shadcn-porter/ct.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from "@muddle/contracts"; +import { stcssIntegration } from "@muddle/views/integrations"; +import { shadcnIntegration } from "@muddle/shadcn"; + +export default defineConfig({ + root: import.meta.dir, + views: "modules/app/views", + viewRoots: ["modules/app/views"], + styles: "styles", + integrations: [ + shadcnIntegration({ components: ["button", "card"] }), + stcssIntegration({ dir: "styles" }), + ], + prerender: { routes: ["/"], outDir: "dist" }, +}); diff --git a/samples/shadcn-porter/dist.previous/_muddle/styles.css b/samples/shadcn-porter/dist.previous/_muddle/styles.css new file mode 100644 index 0000000..23bec7a --- /dev/null +++ b/samples/shadcn-porter/dist.previous/_muddle/styles.css @@ -0,0 +1,93 @@ +:root { + --background: #ffffff; + --foreground: #09090b; + --card: #ffffff; + --card-foreground: #09090b; + --border: #e4e4e7; + --primary: #18181b; + --primary-foreground: #fafafa; + --muted-foreground: #71717a; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, sans-serif; +} +.porter { + display: grid; + gap: 1.5rem; + width: min(100% - 2rem, 64rem); + margin: 0 auto; + padding: clamp(2rem, 9vw, 7rem) 0; +} +.porter__intro, .porter__item { + padding: clamp(1.25rem, 4vw, 3rem); +} +.porter__intro h1 { + max-width: 44rem; + margin: 0; + font-size: clamp(2.75rem, 7vw, 5.75rem); + line-height: .95; + letter-spacing: -.07em; +} +.porter__intro p, .porter__item p { + max-width: 39rem; + color: var(--muted-foreground); + line-height: 1.65; +} +.porter__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} +.porter__item { + min-height: 15rem; + display: grid; + align-content: space-between; + gap: 2rem; +} +.porter__item h2 { + margin: 0; + font-size: 1.35rem; +} +.rounded-xl { + border-radius: .75rem; +} +.border { + border: 1px solid var(--border); +} +.bg-card { + background: var(--card); +} +.text-card-foreground { + color: var(--card-foreground); +} +.shadow { + box-shadow: 0 1px 3px rgb(0 0 0 / .1), 0 1px 2px rgb(0 0 0 / .06); +} +.muddle-react { + display: inline-flex; + width: fit-content; + align-items: center; + justify-content: center; + border: 0; + border-radius: .375rem; + background: var(--primary); + color: var(--primary-foreground); + padding: .55rem .9rem; + font: inherit; + font-weight: 500; + cursor: pointer; +} +.muddle-react:hover { + opacity: .9; +} +@media (max-width: 48rem) { + .porter__grid { + grid-template-columns: 1fr; + } +} \ No newline at end of file diff --git a/samples/shadcn-porter/dist.previous/index.html b/samples/shadcn-porter/dist.previous/index.html new file mode 100644 index 0000000..6141170 --- /dev/null +++ b/samples/shadcn-porter/dist.previous/index.html @@ -0,0 +1,102 @@ +Porter

ShadCN, ported to Muddle.

This page is assembled from the Button and Card components fetched through the configured ShadCN registry integration.

Button

This is the generated Muddle port, not a hand-built replacement.

Card

This is the generated Muddle port, not a hand-built replacement.

\ No newline at end of file diff --git a/samples/shadcn-porter/index.ts b/samples/shadcn-porter/index.ts new file mode 100644 index 0000000..414a472 --- /dev/null +++ b/samples/shadcn-porter/index.ts @@ -0,0 +1,32 @@ +import { join } from "node:path"; +import { serve } from "@muddle/adapters/server-bun"; +import { prerender } from "@muddle/adapters/static"; +import { App } from "@muddle/core/app"; +import { createViews } from "@muddle/views"; +import { AppModule } from "./modules/app/app.module.ts"; +import { resolved } from "./config.ts"; + +const views = createViews({ + root: resolved.root, + viewsDir: resolved.views, + componentsDir: resolved.components, +}); +export const app = await App.create({ + modules: [AppModule], + views, + config: resolved, + integrations: resolved.integrations, +}); + +export async function build() { + await prerender(app, { + ...resolved.prerender, + outDir: join(resolved.root, "dist"), + predict: { breakpointAcc: ["XL", "LG", "MD", "SM"] }, + }); +} + +if (import.meta.main) { + if (process.argv.includes("--prerender")) await build(); + else console.log(`shadcn porter on ${serve(app, { port: 3070 }).url}`); +} diff --git a/samples/shadcn-porter/modules/app/app.module.ts b/samples/shadcn-porter/modules/app/app.module.ts new file mode 100644 index 0000000..451b582 --- /dev/null +++ b/samples/shadcn-porter/modules/app/app.module.ts @@ -0,0 +1,5 @@ +import { Module } from "@muddle/decorators"; +import { LandingController } from "./landing.controller.ts"; + +@Module({ controllers: [LandingController] }) +export class AppModule {} diff --git a/samples/shadcn-porter/modules/app/landing.controller.ts b/samples/shadcn-porter/modules/app/landing.controller.ts new file mode 100644 index 0000000..0d81d58 --- /dev/null +++ b/samples/shadcn-porter/modules/app/landing.controller.ts @@ -0,0 +1,10 @@ +import { Controller, View, ViewRoot } from "@muddle/decorators"; + +@Controller("/") +@ViewRoot("./views") +export class LandingController { + @View("index.spug") + index() { + return { title: "Porter", components: ["Button", "Card"] }; + } +} diff --git a/samples/shadcn-porter/modules/app/views/components/button.spug b/samples/shadcn-porter/modules/app/views/components/button.spug new file mode 100644 index 0000000..a10c79d --- /dev/null +++ b/samples/shadcn-porter/modules/app/views/components/button.spug @@ -0,0 +1,3 @@ +button(class="muddle-react") + if content + != await content() diff --git a/samples/shadcn-porter/modules/app/views/components/card.spug b/samples/shadcn-porter/modules/app/views/components/card.spug new file mode 100644 index 0000000..d5ff462 --- /dev/null +++ b/samples/shadcn-porter/modules/app/views/components/card.spug @@ -0,0 +1,3 @@ +div(class="rounded-xl border bg-card text-card-foreground shadow") + if content + != await content() diff --git a/samples/shadcn-porter/modules/app/views/index.spug b/samples/shadcn-porter/modules/app/views/index.spug new file mode 100644 index 0000000..4da6b6f --- /dev/null +++ b/samples/shadcn-porter/modules/app/views/index.spug @@ -0,0 +1,25 @@ +doctype html +html(lang="en") + head + meta(charset="UTF-8") + meta(name="viewport" content="width=device-width, initial-scale=1") + title= title + link(rel="stylesheet" href="/_muddle/styles.css") + body + main.porter + component card + render content + section.porter__intro + h1 ShadCN ported to Muddle. + component button + render content + | Test + section.porter__grid + each name in components + component card + render content + article.porter__item + h2= name + component button + render content + | Tspmo diff --git a/samples/shadcn-porter/styles/landing.stcss b/samples/shadcn-porter/styles/landing.stcss new file mode 100644 index 0000000..6eee9dd --- /dev/null +++ b/samples/shadcn-porter/styles/landing.stcss @@ -0,0 +1,97 @@ +:root { + --background: #ffffff; + --foreground: #09090b; + --card: #ffffff; + --card-foreground: #09090b; + --border: #e4e4e7; + --primary: #18181b; + --primary-foreground: #fafafa; + --muted-foreground: #71717a; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + background: var(--background); + color: var(--foreground); + font-family: ui-sans-serif, system-ui, sans-serif; +} +.porter { + display: grid; + gap: 1.5rem; + width: min(100% - 2rem, 64rem); + margin: 0 auto; + padding: clamp(2rem, 9vw, 7rem) 0; +} +.porter__intro, +.porter__item { + padding: clamp(1.25rem, 4vw, 3rem); +} +.porter__intro h1 { + max-width: 44rem; + margin: 0; + font-size: clamp(2.75rem, 7vw, 5.75rem); + line-height: 0.95; + letter-spacing: -0.07em; +} +.porter__intro p, +.porter__item p { + max-width: 39rem; + color: var(--muted-foreground); + line-height: 1.65; +} +.porter__grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} +.porter__item { + min-height: 15rem; + display: grid; + align-content: space-between; + gap: 2rem; +} +.porter__item h2 { + margin: 0; + font-size: 1.35rem; +} +.rounded-xl { + border-radius: 0.75rem; +} +.border { + border: 1px solid var(--border); +} +.bg-card { + background: var(--card); +} +.text-card-foreground { + color: var(--card-foreground); +} +.shadow { + box-shadow: + 0 1px 3px rgb(0 0 0 / 0.1), + 0 1px 2px rgb(0 0 0 / 0.06); +} +.muddle-react { + display: inline-flex; + width: fit-content; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0.375rem; + background: var(--primary); + color: var(--primary-foreground); + padding: 0.55rem 0.9rem; + font: inherit; + font-weight: 500; + cursor: pointer; +} +.muddle-react:hover { + opacity: 0.9; +} +@media (max-width: 48rem) { + .porter__grid { + grid-template-columns: 1fr; + } +} diff --git a/samples/shadcn-porter/tsconfig.json b/samples/shadcn-porter/tsconfig.json new file mode 100644 index 0000000..4037c0c --- /dev/null +++ b/samples/shadcn-porter/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "paths": { + "@muddle/adapters/*": ["../../adapters/*"], + "@muddle/core/*": ["../../core/*"], + "@muddle/decorators": ["../../decorators/index.ts"], + "@muddle/contracts": ["../../contracts/index.ts"], + "@muddle/views": ["../../views/index.ts"], + "@muddle/views/*": ["../../views/*"], + "@muddle/shadcn": ["../../modules/shadcn"], + "$types": ["./_muddle/contracts/*"] + } + } +} diff --git a/tests/amezing.test.ts b/tests/amezing.test.ts new file mode 100644 index 0000000..1c4629f --- /dev/null +++ b/tests/amezing.test.ts @@ -0,0 +1,30 @@ +import { expect, test } from "bun:test"; +import { App } from "../index.ts"; +import { resolveConfig } from "../core/config.ts"; +import { createViews } from "../views/api.ts"; +import { amezingIntegration, amezingStyles, accentStyle } from "../modules/amezing/index.ts"; + +function createAmezingApp() { + const views = createViews({ viewsDir: process.cwd() }); + return App.create({ config: resolveConfig(), views, integrations: [amezingIntegration()] }); +} + +test("amezing integration mounts the theme components and assets", async () => { + const app = await createAmezingApp(); + const response = await app.fetch(new Request("https://example.test/_muddle/amezing/closable.js")); + expect(response.status).toBe(200); + const font = await app.fetch(new Request("https://example.test/fonts/commissioner.woff2")); + expect(font.status).toBe(200); + const styles = await app.fetch(new Request("https://example.test/_muddle/amezing.css")); + expect((await styles.text()).length).toBeGreaterThan(0); +}); + +test("amezing accent colors map presets to inline variables", () => { + expect(accentStyle("purple")).toContain("--accent-light-h:254.11766"); + expect(accentStyle(["red", "green"])).toContain("--accent-dark-h:90"); + expect(() => accentStyle("nope")).toThrow(); +}); + +test("amezing ships the compiled theme stylesheet", async () => { + expect(await amezingStyles()).toContain("#site-header"); +}); diff --git a/tests/analysis.test.ts b/tests/analysis.test.ts new file mode 100644 index 0000000..d1b6f67 --- /dev/null +++ b/tests/analysis.test.ts @@ -0,0 +1,34 @@ +import { expect, test } from "bun:test"; +import { analyzeHtml } from "../modules/analysis/index.ts"; +import { App } from "../core/app.ts"; +import { terserIntegration, htmlMinifyIntegration, imageOptimizationIntegration } from "../integrations/optimize.ts"; + +test("accessibility scoring reports semantics, alpha contrast and visual-effect uncertainty", async () => { + const html = 'Audit

Low contrast

Filtered text

'; + const [report] = await analyzeHtml(html, "accessible", { viewports: [{ width: 390, height: 844 }] }); + expect(report!.findings.map((finding) => finding.rule)).toContain("control-name"); + expect(report!.findings.map((finding) => finding.rule)).toContain("text-contrast"); + expect(report!.findings.map((finding) => finding.rule)).toContain("visibility-effects"); + expect(report!.score).toBeLessThan(100); + expect(report!.findings.find((finding) => finding.rule === "visibility-effects")!.severity).toBe("review"); +}); + +test("overflow analysis distinguishes intentional scrolling and clipped content", async () => { + const html = '
Wide
Scrollable
Clipped
'; + const reports = await analyzeHtml(html, "overflow"); + expect(reports[0]!.findings.filter((finding) => finding.rule === "viewport-overflow")).toHaveLength(0); + expect(reports[1]!.findings.filter((finding) => finding.rule === "viewport-overflow")).toHaveLength(1); + expect(reports[1]!.findings.map((finding) => finding.rule)).toContain("clipping"); +}); + +test("async optimization runs on staged assets and preserves binary image input", async () => { + let bytes: number[] = []; + const app = await App.create({ integrations: [terserIntegration(), htmlMinifyIntegration(), imageOptimizationIntegration({ async optimize(input) { bytes = [...input]; return new Uint8Array([9, 8]); } })] }); + const js = await app.transformAsset({ path: "app.js", contentType: "text/javascript", content: 'const url = "https://example.test"; console.log(url);' }); + expect(String(js.content)).toContain("https://example.test"); + const html = await app.transformAsset({ path: "index.html", contentType: "text/html", content: '

Text

' }); + expect(String(html.content)).not.toContain(" "); + const image = await app.transformAsset({ path: "image.png", contentType: "image/png", content: new Uint8Array([0, 255, 128]) }); + expect(bytes).toEqual([0, 255, 128]); + expect([...image.content as Uint8Array]).toEqual([9, 8]); +}); diff --git a/tests/backport.test.ts b/tests/backport.test.ts new file mode 100644 index 0000000..480d7ae --- /dev/null +++ b/tests/backport.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { analyzeReactComponent, astroToSpug, backportProject, cssToStcss, htmlToSpug, reactToSpug, zolaToSpug } from "../core/backport.ts"; +import { shadcnIntegration } from "../integrations/shadcn.ts"; +import { sassLoader } from "../views/css/sass.ts"; +import { TemplateEngine } from "../views/engine/runtime.ts"; + +describe("backport", () => { + test("rebuilds normalized CSS as STCSS", () => { + const result = cssToStcss(".card { color: red; &:hover { color: blue; } }"); + expect(result).toContain(".card {"); + expect(result).toContain("color: red;"); + }); + + test("turns HTML structures into indented SPUG", () => { + const result = htmlToSpug('

Hello

'); + expect(result).toContain("main.page"); + expect(result).toContain(" h1"); + expect(result).toContain('img(src="hero.png")'); + }); + + test("ports Astro layouts, slots and imported components", () => { + const page = astroToSpug(`---\nimport Layout from "../layouts/Layout.astro";\nimport Card from "../components/Card.astro";\n---\n

Body

`, "/site/src/pages/index.astro", "/site/src/pages"); + expect(page).toContain("extends layouts/Layout.spug"); + expect(page).toContain("block content"); + expect(page).toContain("component card"); + expect(page).toContain("title: title"); + expect(astroToSpug("
")).toContain("block content"); + }); + + test("ports Zola inheritance, partials and template expressions", () => { + const page = zolaToSpug(`{% extends "base.html" %}\n{% block content %}

{{ page.title }}

{% include "partials/nav.html" %}{% endblock %}`); + expect(page).toContain("extends ./base.spug"); + expect(page).toContain("block content"); + expect(page).toContain("include ./partials/nav.spug"); + expect(page).toContain("#{ page.title }"); + }); + + test("ports React JSX components through the TypeScript AST", () => { + const component = reactToSpug('import { Button } from "./button"; export function Card({ title }) { return

{title}

; }'); + expect(component).toContain("section(class=\"card\")"); + expect(component).toContain("= title"); + expect(component).toContain("component button"); + expect(component).toContain('variant: "outline"'); + }); + + test("analyzes hooks and lowers static state into a javascript block", () => { + const source = 'function Toggle() { const [open] = useState(false); useEffect(() => {}, []); return ; }'; + expect(analyzeReactComponent(source).hooks).toEqual(["useEffect", "useState"]); + expect(reactToSpug(source)).toContain("javascript!"); + expect(reactToSpug(source)).toContain("const open = false;"); + }); + + test("creates a Muddle project from an Astro folder", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-backport-")); + await Bun.write(join(root, "astro.config.mjs"), "export default {}"); + await mkdir(join(root, "src/pages"), { recursive: true }); + await mkdir(join(root, "src/styles"), { recursive: true }); + await mkdir(join(root, "src/content/posts"), { recursive: true }); + await mkdir(join(root, "src/layouts"), { recursive: true }); + await mkdir(join(root, "src/components"), { recursive: true }); + await Bun.write(join(root, "src/pages/index.astro"), "

Hi

"); + await Bun.write(join(root, "src/pages/about.astro"), "---\nimport Shell from '../layouts/Shell.astro';\n---\n

About

"); + await Bun.write(join(root, "src/layouts/Shell.astro"), ""); + await Bun.write(join(root, "src/components/Card.astro"), "
"); + await Bun.write(join(root, "src/styles/site.css"), ".page { color: red; }"); + await Bun.write(join(root, "src/content/posts/hello.md"), "---\ntitle: Hello\n---\n\nBody"); + const destination = join(root, "muddle"); + const result = await backportProject(root, destination); + expect(result.source).toBe("astro"); + expect(await readFile(join(destination, "modules/app/views/index.spug"), "utf8")).toContain("main"); + expect(await readFile(join(destination, "styles/site.stcss"), "utf8")).toContain(".page"); + expect(await readFile(join(destination, "content/posts/hello.md"), "utf8")).toContain("title: Hello"); + expect(await readFile(join(destination, "ct.config.ts"), "utf8")).toContain('name: "posts"'); + const engine = new TemplateEngine({ viewsDir: join(destination, "modules/app/views") }); + expect(await engine.render("index.spug")).toContain("

Hi

"); + expect(await engine.render("about.spug")).toContain("

About

"); + }); + + test("carries Zola configuration and partial templates into the Muddle project", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-zola-")); + await mkdir(join(root, "templates/partials"), { recursive: true }); + await mkdir(join(root, "content/posts"), { recursive: true }); + await Bun.write(join(root, "config.toml"), 'title = "Zola notes"\nbase_url = "https://notes.example"'); + await Bun.write(join(root, "templates/index.html"), '{% extends "base.html" %}{% block content %}

{{ config.title }}

{% endblock %}'); + await Bun.write(join(root, "templates/partials/nav.html"), ""); + await Bun.write(join(root, "content/posts/first.md"), "---\ntitle: First\n---\n\nPost"); + const destination = join(root, "muddle"); + await backportProject(root, destination); + expect(await readFile(join(destination, "modules/app/site.config.ts"), "utf8")).toContain("Zola notes"); + expect(await readFile(join(destination, "modules/app/views/partials/nav.spug"), "utf8")).toContain("nav"); + expect(await readFile(join(destination, "modules/app/views/index.spug"), "utf8")).toContain("extends ./base.spug"); + }); + + test("compiles Sass before handing CSS to STCSS", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-sass-")); + const path = join(root, "site.scss"); + await writeFile(path, "$ink: #123456; .card { color: $ink; }"); + const loaded = await sassLoader().load({ path, relativePath: "styles/site.scss", source: await readFile(path, "utf8") }); + expect(loaded.content).toContain("color: #123456;"); + }); + + test("pulls declarative ShadCN registry components into Spug and STCSS", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-shadcn-")); + const integration = shadcnIntegration({ + components: ["button"], + fetch: async () => new Response(JSON.stringify({ files: [ + { path: "button.tsx", content: "export function Button() { return ; }" }, + { path: "button.css", content: ".btn { color: red; }" }, + ] })), + }); + await integration.setup?.({ config: { root } as any, route() {}, loader() {} }); + expect(await readFile(join(root, "modules/app/views/components/button.spug"), "utf8")).toContain("button"); + expect(await readFile(join(root, "styles/shadcn/button.stcss"), "utf8")).toContain(".btn"); + }); +}); diff --git a/tests/components.test.ts b/tests/components.test.ts new file mode 100644 index 0000000..537c4f2 --- /dev/null +++ b/tests/components.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { app } from "../samples/hello-world/index.ts"; + +/** + * The minimal blog deliberately has no application server components. + */ +describe("server components", () => { + test("renders the static blog shell", async () => { + const html = await ( + await app.fetch(new Request("http://localhost/")) + ).text(); + expect(html).toContain("Test1"); + }); + + test("does not ship a relation bridge for anchors", async () => { + const html = await ( + await app.fetch(new Request("http://localhost/")) + ).text(); + expect(html).not.toContain("window.__muddleRelations"); + }); +}); diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..3d7b7f9 --- /dev/null +++ b/tests/core.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { generate, renderProps } from "../contracts/typegen/generator.ts"; +import { Container } from "../core/container.ts"; +import { Module } from "../decorators/Module.ts"; +import { Service } from "../decorators/Service.ts"; +import { Controller } from "../decorators/Controller.ts"; +import { Route } from "../decorators/Route.ts"; +import { validate, toFormFields } from "../core/form.ts"; + +/** + * Tests of the container, of the modules and of the type generator. + */ + +describe("container", () => { + test("resolves a dependency by type", () => { + @Service() + class UserService { + find() { + return "ada"; + } + } + + @Controller("/") + class UserController { + constructor(private readonly users: UserService) {} + + find() { + return this.users.find(); + } + } + + const container = new Container(); + container.registerClass(UserService); + container.registerClass(UserController); + + const controller = container.resolve(UserController); + expect(controller.find()).toBe("ada"); + }); + + test("awaits an asynchronous provider", async () => { + @Service() + class AsyncService { + async load() { + return "loaded"; + } + } + + const container = new Container(); + container.registerFactory( + AsyncService, + async () => new AsyncService(), + ); + + const service = await container.resolveAsync(AsyncService); + expect(await service.load()).toBe("loaded"); + }); + + test("reports an unknown dependency", () => { + const container = new Container(); + expect(() => container.resolve("missing")).toThrow("Cannot resolve"); + }); +}); + +describe("modules", () => { + test("collects the controllers of a module", () => { + @Controller("/") + class HomeController { + @Route("/") + index() { + return "home"; + } + } + + @Module({ controllers: [HomeController] }) + class AppModule {} + + const container = new Container(); + container.registerClass(HomeController); + + expect(AppModule).toBeDefined(); + }); +}); + +describe("forms", () => { + test("reads the fields of a form", () => { + const fields = toFormFields({ title: "Hello", tags: ["a", "b"] }); + expect(fields.get("title")).toBe("Hello"); + expect(fields.has("tags")).toBe(true); + expect(fields.all().tags).toEqual(["a", "b"]); + }); + + test("validates the fields", () => { + const fields = toFormFields({ title: "Hello", age: "42" }); + const result = validate<{ title: string; age: number }>(fields, { + title: { required: true, maxLength: 10 }, + age: { type: "number", min: 18 }, + }); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.age).toBe(42); + } + }); + + test("reports the invalid fields", () => { + const fields = toFormFields({ title: "", age: "abc" }); + const result = validate(fields, { + title: { required: true }, + age: { type: "number" }, + }); + expect(result.ok).toBe(false); + expect(result.errors.title).toBe("title is required"); + expect(result.errors.age).toBe("age must be a number"); + }); +}); + +describe("typegen", () => { + test("renders the props of a view", () => { + expect(renderProps([])).toBe("Record"); + expect( + renderProps([ + { name: "title", type: "string", optional: false }, + { name: "body", type: "string", optional: true }, + ]), + ).toBe("{ title: string; body?: string }"); + }); + + test("generates the contracts of the templates", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-typegen-")); + await writeFile( + join(root, "index.spug"), + "props\n title: string\n count?: number\n\np= title", + "utf8", + ); + const result = await generate({ + root, + out: join(root, "out"), + viewRoots: ["."], + }); + expect(result.views).toHaveLength(1); + expect(result.views[0]?.key).toBe("index"); + expect(result.views[0]?.props.map((prop) => prop.name)).toEqual([ + "title", + "count", + ]); + + await rm(root, { recursive: true, force: true }); + }); +}); diff --git a/tests/css.test.ts b/tests/css.test.ts new file mode 100644 index 0000000..8dae563 --- /dev/null +++ b/tests/css.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test"; +import { compileCss } from "../views/css/index.ts"; +import { evaluate } from "../views/css/evaluate.ts"; +import { defineHooks } from "../views/hooks.ts"; + +/** + * Tests of the structured css. + */ + +describe("stcss", () => { + test("flattens the nested rules", () => { + const css = compileCss(` +.card { + color: red; + + &__title { + font-weight: 700; + } +} +`); + expect(css).toContain(".card {"); + expect(css).toContain(".card__title {"); + expect(css).toContain("font-weight: 700;"); + }); + + test("accepts Pug-like indentation, colonless declarations and blocks", () => { + const css = compileCss(` +$ink #101828 +.card + color $ink + padding 1rem + &__title + font-weight 700 + &:hover + color #000 +`); + expect(css).toContain(".card {"); + expect(css).toContain("color: #101828;"); + expect(css).toContain("padding: 1rem;"); + expect(css).toContain(".card__title {"); + expect(css).toContain(".card:hover {"); + }); + + test("accepts Pug-like at-rule blocks", () => { + const css = compileCss(` +.panel + width: 30rem + @media (max-width: 48rem) + width 100% +`); + expect(css).toContain(".panel {"); + expect(css).toContain("@media (max-width: 48rem)"); + expect(css).toContain("width: 100%;"); + }); + + test("runs application-defined STCSS expansions from a contextual registry", () => { + const hooks = defineHooks((hook) => { + hook.stcss.value("double", ({ inner, resolve }) => `calc(${resolve(inner)} * 2)`); + hook.stcss.declaration(({ selector, declaration }) => declaration.property === "outline-token" + ? [`${selector}::before { content: "hook"; }`] + : undefined); + hook.stcss.atRule("paint", ({ selector, params }) => `${selector} { color: ${params}; }`); + }); + const css = compileCss(`.card { width: double(12px); outline-token: yes; @paint rebeccapurple; }`, { hooks }); + expect(css).toContain("width: calc(12px * 2);"); + expect(css).toContain('.card::before { content: "hook"; }'); + expect(css).toContain(".card { color: rebeccapurple; }"); + }); + + test("expands calculation hooks and centered position aliases", () => { + const css = compileCss(` +.dialog { + position: fixed-center; + width: 30rem; + margin-left: $offset($center(30rem), $pad); + padding: $pad(2); +} +.page-dialog { position: page-center; transform: scale(.96); } +`); + expect(css).toContain("position: fixed;"); + expect(css).toContain("left: 50%;"); + expect(css).toContain("top: 50%;"); + expect(css).toContain("transform: translate(-50%, -50%);"); + expect(css).toContain("margin-left: calc(calc(50% - (30rem / 2)) + var(--muddle-pad, 1rem));"); + expect(css).toContain("padding: calc(var(--muddle-pad, 1rem) * 2);"); + expect(css).toContain("position: absolute;"); + expect(css).toContain("transform: translate(-50%, -50%) scale(.96);"); + }); + + test("registers application calculation hooks", () => { + const hooks = defineHooks((hook) => { + hook.stcss.calculation("third", ({ args, resolve }) => `calc(${resolve(args ?? "100%")} / 3)`); + }); + expect(compileCss(".column { width: $third(90%); }", { hooks })).toContain("width: calc(90% / 3);"); + }); + + test("resolves the variables", () => { + const css = compileCss(` +$ink: #101828; + +.body { + color: $ink; +} +`); + expect(css).toContain("color: #101828;"); + }); + + test("interpolates an expression", () => { + const css = compileCss(` +$space: 1; + +.gutter { + padding: #{$space * 2}rem; +} +`); + expect(css).toContain("padding: 2rem;"); + }); + + test("repeats a block", () => { + const css = compileCss(` +@each $space in 1 2 3 { + .space-#{$space} { + margin: #{$space * 0.5}rem; + } +} +`); + expect(css).toContain(".space-1 {"); + expect(css).toContain(".space-2 {"); + expect(css).toContain(".space-3 {"); + expect(css).toContain("margin: 1.5rem;"); + }); + + test("counts a range", () => { + const css = compileCss(` +@for $i from 1 through 2 { + .col-#{$i} { + width: #{$i * 10}%; + } +} +`); + expect(css).toContain(".col-1 {"); + expect(css).toContain(".col-2 {"); + }); + + test("expands a mixin", () => { + const css = compileCss(` +@mixin rounded($size) { + border-radius: $size; +} + +.button { + @include rounded(4px); + color: blue; +} +`); + expect(css).toContain("border-radius: 4px;"); + expect(css).toContain("color: blue;"); + }); + + test("keeps the css functions untouched", () => { + const css = compileCss(` +.box { + border: 1px solid rgba(16, 24, 40, 0.12); + color: rgb(1, 2, 3); +} +`); + expect(css).toContain("rgba(16, 24, 40, 0.12)"); + expect(css).toContain("rgb(1, 2, 3)"); + }); + + test("darkens a colour", () => { + const css = compileCss(` +.button { + color: darken(#2b6cb0, 10); +} +`); + expect(css).toMatch(/color: #[0-9a-f]{6};/); + }); + + test("evaluates an expression", () => { + expect(evaluate("$a * 2", { scope: { $a: "21" } })).toBe("42"); + expect(evaluate("$a + 1", { scope: { $a: "1" } })).toBe("2"); + }); + + test("compiles anchor relations, loop ranges and back-propagated state", () => { + const css = compileCss(` +@each $n in [0..2] { + nav:with(@hello$n:active) li:nth-child(#{$n + 1}) { + left: calc($relate(d"@hello{$n + 1}") + $relate[width](d"@hello{$n + 1}")); + color: @hello$n.computed.backgroundColor; + } +} +nav li:when(nav @hello1:clicked) { opacity: 1; } +`); + expect(css).toContain('nav:has([data-muddle-anchor="hello0"]:active) li:nth-child(1)'); + expect(css).toContain("var(--muddle-relate-hello1-x)"); + expect(css).toContain("var(--muddle-relate-hello1-width)"); + expect(css).toContain('nav:has([data-muddle-anchor="hello1"]:active) li'); + }); + + test("resolves $refer as a relation value", () => { + const css = compileCss(`.tooltip { left: $refer[left](@trigger); width: $refer[width](@trigger); }`); + expect(css).toContain("var(--muddle-relate-trigger-x)"); + expect(css).toContain("var(--muddle-relate-trigger-width)"); + }); + + test("reflects dotted anchor groups without enumerating their members", () => { + const css = compileCss(`:reflect(@docs) { scroll-margin-top: 4rem; }`); + expect(css).toContain('[data-muddle-anchor^="docs."]'); + }); + + test("expands concentric border radii", () => { + const css = compileCss(` +.panel { + padding: 1rem; + border: 2px solid #333; + border-radius: concentric 0.75rem; +} +.panel-default { + border-radius: concentric; +} +.blind { + padding: 12px; + border: var(--bw, 1px) solid; + border-radius: concentric var(--outer, 12px); +} +`); + expect(css).toContain(".panel > * {\n\tborder-radius: calc((0.75rem) - (1rem) - (2px));"); + expect(css).toContain(".panel-default > * {\n\tborder-radius: inherit;"); + expect(css).toContain(".blind > * {\n\tborder-radius: calc((var(--outer, 12px)) - (12px) - (var(--bw, 1px)));"); + }); + + test("expands freeform gradients into radial gradient stacks", () => { + const css = compileCss(` +.paint { + background-image: freeform-gradient(hsl(270 50% 60%) 10% 50%, red 70% 40%, rgb(0 128 0) 40% 80% 30%); +} +`); + expect(css).toContain("radial-gradient(circle farthest-side at 10% 50%, hsl(270 50% 60%), transparent)"); + expect(css).toContain("radial-gradient(circle farthest-side at 70% 40%, red, transparent)"); + expect(css).toContain("radial-gradient(circle at 40% 80%, rgb(0 128 0), rgb(0 128 0) calc(30% * 0.97), transparent 30%)"); + expect(() => compileCss(".x { background-image: freeform-gradient(red 1% 2% 3% 4%); }")).toThrow(); + }); +}); + +describe("stcss @measure", () => { + test("records prediction targets and anchors them as relations", () => { + const measured: string[] = []; + const css = compileCss(` +.ribbon { + @measure; +} +@measure @trigger, .card, panel; +`, { measured }); + expect(css).toContain("/* muddle-predict:"); + expect(measured).toEqual([ + ".ribbon", + '[data-muddle-anchor="trigger"]', + ".card", + "panel", + ]); + }); +}); diff --git a/tests/d1.test.ts b/tests/d1.test.ts new file mode 100644 index 0000000..3b3c6c9 --- /dev/null +++ b/tests/d1.test.ts @@ -0,0 +1,66 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { createD1Driver } from "../modules/database/src/adapters/d1.ts"; + +const server = Bun.serve({ + port: 0, + async fetch(request) { + const payload = (await request.json()) as { + sql?: string; + params?: unknown[]; + }; + + if (payload.sql?.startsWith("SELECT")) { + return Response.json({ + success: true, + result: [ + { + results: [ + { id: "1", title: "First", position: 1 }, + ], + success: true, + meta: { changes: 1 }, + }, + ], + }); + } + + return Response.json({ + success: true, + result: [{ results: [], success: true, meta: { changes: 1 } }], + }); + }, +}); + +afterAll(() => { + server.stop(true); +}); + +describe("d1", () => { + test("sends the statements to the api", async () => { + const driver = createD1Driver({ + accountId: "account", + databaseId: "database", + apiToken: "token", + endpoint: `http://localhost:${server.port}/query`, + }); + + const rows = await driver.query("SELECT * FROM notes"); + expect(rows).toHaveLength(1); + expect(rows[0]?.title).toBe("First"); + + const result = await driver.execute("INSERT INTO notes VALUES (1)"); + expect(result.changes).toBe(1); + + await driver.close(); + }); + + test("uses the placeholders of sqlite", () => { + const driver = createD1Driver({ + accountId: "account", + databaseId: "database", + apiToken: "token", + }); + expect(driver.placeholder(1)).toBe("?"); + expect(driver.dialect).toBe("sqlite"); + }); +}); diff --git a/tests/hello-world.test.ts b/tests/hello-world.test.ts new file mode 100644 index 0000000..c4506d0 --- /dev/null +++ b/tests/hello-world.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { app } from "../samples/hello-world/index.ts"; + +/** + * End to end test of the hello world sample: the routes are rendered by the + * application, without a http server in between. + */ + +async function get(path: string): Promise { + return app.fetch(new Request(`http://localhost${path}`)); +} + +describe("hello world sample", () => { + test("renders the home page", async () => { + const response = await get("/"); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(""); + expect(html).toContain("Blog"); + expect(html).toContain("Test1"); + expect(html).toContain("Test2"); + }); + + test("renders a single post", async () => { + const response = await get("/posts/test1"); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain("Test1"); + }); + + test("answers with 404 for unknown routes", async () => { + const response = await get("/nope"); + expect(response.status).toBe(404); + }); + +}); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..e4f74eb --- /dev/null +++ b/tests/hooks.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createViews } from "../views/api.ts"; +import { defineHooks } from "../views/hooks.ts"; + +test("Spug hook registry composes render expansions with route context", async () => { + const hooks = defineHooks((hook) => { + hook.spug("stamp", ({ html, path, locals }) => `${html}`); + }); + const html = await hooks.renderSpug({ html: "
hello
", path: "/views/index.spug", locals: { title: "Muddle" } }); + expect(html).toContain(''); +}); + +test("Views applies Spug hooks after template composition", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-hooks-")); + try { + await writeFile(join(root, "index.spug"), "main= title\n"); + const hooks = defineHooks((hook) => { + hook.spug("mark", ({ html }) => `${html}`); + }); + const views = createViews({ root, viewsDir: root, hooks }); + expect(await views.render("index.spug", { title: "Muddle" } as never)).toContain(""); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/integrations.test.ts b/tests/integrations.test.ts new file mode 100644 index 0000000..6b030f6 --- /dev/null +++ b/tests/integrations.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "bun:test"; +import { + checkboxMarkup, + mouseTrackerMarkup, + mouseTrackerCss, + reactCss, + reactIntegration, +} from "../integrations/react.ts"; + +/** + * Tests of the reactivity integration: the state utilities, the native + * checkbox hack and the mouse tracker of the parent hack. + */ + +describe("react integration", () => { + test("compiles the state utilities", () => { + const css = reactCss(); + expect(css).toContain(".state-open"); + expect(css).toContain(".state-closed"); + expect(css).toContain('[data-state="open"]'); + expect(css).toContain("--muddle-state: open;"); + }); + + test("exposes checkbox state without prescribing visual styles", () => { + const css = reactCss(); + expect(css).toContain('.peer:checked ~ [data-state="checked"]'); + expect(css).not.toContain("background:"); + expect(css).not.toContain("box-shadow:"); + }); + + test("scopes the parent hack of the mouse tracker", () => { + const css = mouseTrackerCss({ columns: 4, rows: 3, scope: "card" }); + const rules = css.match(/data-muddle-tracker="card"\]:has/g) ?? []; + expect(rules).toHaveLength(12); + expect(css).toContain("--muddle-mouse-x:"); + expect(css).toContain("nth-child(1)"); + expect(css).toContain("nth-child(12)"); + }); + + test("renders the markup of the mouse tracker", () => { + const markup = mouseTrackerMarkup({ columns: 2, rows: 2 }); + expect((markup.match(/mouse-tracker__cell/g) ?? [])).toHaveLength(4); + expect(markup).toContain('class="mouse-tracker"'); + expect(markup).toContain('data-muddle-capture="false"'); + }); + + test("renders the markup of a checkbox", () => { + const markup = checkboxMarkup({ + name: "published", + label: "Published", + checked: true, + }); + expect(markup).toContain('type="checkbox"'); + expect(markup).toContain('name="published"'); + expect(markup).toContain("checked"); + expect(markup).toContain("Published"); + }); + + test("serves the stylesheet", async () => { + const routes: { method: string; path: string }[] = []; + const integration = reactIntegration(); + await integration.setup?.({ + config: { + root: process.cwd(), + views: "views", + components: "views/components", + content: "content", + styles: "styles", + publicDir: "public", + viewRoots: ["views"], + loaders: {}, + collections: [], + integrations: [], + }, + route: (method, path) => routes.push({ method, path }), + loader: () => undefined, + }); + + expect(routes).toEqual([ + { method: "GET", path: "/_muddle/react.css" }, + ]); + }); +}); diff --git a/tests/markdown.test.ts b/tests/markdown.test.ts new file mode 100644 index 0000000..2337bb9 --- /dev/null +++ b/tests/markdown.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ContentRegistry } from "../core/content.ts"; +import { resolveConfig } from "../core/config.ts"; +import { markdownLoader, parseFrontmatter, renderMarkdown } from "../views/markdown.ts"; + +/** + * Tests of the markdown loader and of the content collections. + */ + +describe("markdown", () => { + test("parses the frontmatter", () => { + const { frontmatter, body } = parseFrontmatter( + "---\ntitle: Hello\ndraft: false\ncount: 3\ntags:\n - a\n - b\n---\n\nBody", + ); + expect(frontmatter.title).toBe("Hello"); + expect(frontmatter.draft).toBe(false); + expect(frontmatter.count).toBe(3); + expect(frontmatter.tags).toEqual(["a", "b"]); + expect(body.trim()).toBe("Body"); + }); + + test("renders the inline markup", () => { + expect(renderMarkdown("**bold** and *italic*")).toContain("bold"); + expect(renderMarkdown("`code`")).toContain("code"); + expect(renderMarkdown("[link](/x)")).toContain('link'); + }); + + test("renders the blocks", () => { + const html = renderMarkdown( + "# Title\n\nA paragraph.\n\n- one\n- two\n\n> quoted\n\n```js\nconst a = 1;\n```", + ); + expect(html).toContain("

Title

"); + expect(html).toContain("

A paragraph.

"); + expect(html).toContain("
  • one
  • "); + expect(html).toContain("
    "); + expect(html).toContain("
    ");
    +	});
    +});
    +
    +describe("content collections", () => {
    +	test("loads the entries of a collection", async () => {
    +		const dir = await mkdtemp(join(tmpdir(), "muddle-content-"));
    +		await writeFile(
    +			join(dir, "first.md"),
    +			"---\ntitle: First\n---\n\nHello **world**",
    +			"utf8",
    +		);
    +		await writeFile(
    +			join(dir, "second.md"),
    +			"---\ntitle: Second\n---\n\nSecond note",
    +			"utf8",
    +		);
    +
    +		const registry = new ContentRegistry(
    +			resolveConfig({
    +				root: dir,
    +				collections: [
    +					{
    +						name: "notes",
    +						dir: ".",
    +						loader: markdownLoader(),
    +						schema: { required: ["title"] },
    +					},
    +				],
    +			}),
    +		);
    +		await registry.load();
    +
    +		const entries = registry.entries("notes");
    +		expect(entries).toHaveLength(2);
    +		expect(entries[0]?.id).toBe("first");
    +		expect(entries[0]?.frontmatter.title).toBe("First");
    +		expect(entries[0]?.body).toContain("world");
    +		expect(registry.get("notes", "second")?.frontmatter.title).toBe("Second");
    +
    +		await rm(dir, { recursive: true, force: true });
    +	});
    +
    +	test("rejects an invalid frontmatter", async () => {
    +		const dir = await mkdtemp(join(tmpdir(), "muddle-content-"));
    +		await writeFile(join(dir, "broken.md"), "---\ndraft: true\n---\n\nBody", "utf8");
    +
    +		const registry = new ContentRegistry(
    +			resolveConfig({
    +				root: dir,
    +				collections: [
    +					{
    +						name: "notes",
    +						dir: ".",
    +						loader: markdownLoader(),
    +						schema: { required: ["title"] },
    +					},
    +				],
    +			}),
    +		);
    +
    +		await expect(registry.load()).rejects.toThrow("title is required");
    +
    +		await rm(dir, { recursive: true, force: true });
    +	});
    +});
    diff --git a/tests/modules.test.ts b/tests/modules.test.ts
    new file mode 100644
    index 0000000..ad7a799
    --- /dev/null
    +++ b/tests/modules.test.ts
    @@ -0,0 +1,61 @@
    +import { expect, test } from "bun:test";
    +import { mkdtemp, writeFile, rm } from "node:fs/promises";
    +import { join } from "node:path";
    +import { tmpdir } from "node:os";
    +import { astroComponent } from "../modules/astro/index.ts";
    +import { TemplateContext } from "../core/context.ts";
    +import { createViews } from "../views/api.ts";
    +import { authFromRequest, authGuard, authIntegration } from "../integrations/auth.ts";
    +import { App } from "../core/app.ts";
    +import { resolveConfig } from "../core/config.ts";
    +import { renderMdx } from "../modules/mdx/index.ts";
    +import { compileCss } from "../views/css/index.ts";
    +
    +test("Astro imports compose with typed props and SPUG slots", async () => {
    +	const root = await mkdtemp(join(tmpdir(), "astro-compose-"));
    +	try {
    +		await writeFile(join(root, "Title.astro"), '---\nconst { title } = Astro.props;\n---\n

    {title}

    '); + await writeFile(join(root, "Card.astro"), '---\nimport Title from "./Title.astro";\nconst { title } = Astro.props;\n---\n
    <slot /></article>'); + await writeFile(join(root, "index.spug"), 'component Card\n title: "Composed"\n render content\n p Slot body'); + const context = new TemplateContext().import("Card", astroComponent(join(root, "Card.astro"))); + const views = createViews({ viewsDir: root, context }); + const html = await views.renderFile("index"); + expect(html).toContain("<h2>Composed</h2>"); + expect(html).toContain("<p>Slot body</p>"); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +test("form authentication issues an opaque session and rejects forged identity cookies", async () => { + const app = await App.create({ config: resolveConfig(), integrations: [authIntegration({ login: (email, password) => password === "secret" ? { id: email } : undefined })] }); + const response = await app.fetch(new Request("https://example.test/auth/login", { method: "POST", headers: { origin: "https://example.test" }, body: new URLSearchParams({ email: "ada", password: "secret" }) })); + expect(response.status).toBe(302); + const cookie = response.headers.get("set-cookie")!; + expect((await authFromRequest(new Request("https://example.test", { headers: { cookie } })))?.id).toBe("ada"); + expect(await authFromRequest(new Request("https://example.test", { headers: { cookie: 'muddle_auth=%7B%22id%22%3A%22admin%22%7D' } }))).toBeUndefined(); +}); + +test("OAuth callback validates browser-bound state and exchanges a PKCE code", async () => { + const original = globalThis.fetch; + let exchanged = false; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + if (String(input).endsWith("/token")) { + const body = init?.body as URLSearchParams; + expect(body.get("code_verifier")?.length).toBeGreaterThan(40); + exchanged = true; + return Response.json({ access_token: "test-token" }); + } + return Response.json({ sub: "oauth-user" }); + }) as typeof fetch; + try { + const app = await App.create({ config: resolveConfig(), integrations: [authIntegration({ providers: [{ name: "test", authorizationUrl: "https://provider.test/authorize", tokenUrl: "https://provider.test/token", userInfoUrl: "https://provider.test/user", clientId: "test", redirectUri: "https://app.test/auth/test/callback", identity: (profile) => ({ id: String(profile.sub) }) }] })] }); + const start = await app.fetch(new Request("https://app.test/auth/test")); + const authorization = new URL(start.headers.get("location")!); + expect(authorization.searchParams.get("code_challenge_method")).toBe("S256"); + const callback = `https://app.test/auth/test/callback?code=test-code&state=${authorization.searchParams.get("state")}`; + expect((await app.fetch(new Request(callback))).status).toBe(400); + const response = await app.fetch(new Request(callback, { headers: { cookie: start.headers.get("set-cookie")! } })); + expect(response.status).toBe(302); + expect(exchanged).toBe(true); + expect((await app.fetch(new Request(callback, { headers: { cookie: start.headers.get("set-cookie")! } }))).status).toBe(400); + } finally { globalThis.fetch = original; } +}); diff --git a/tests/orm.test.ts b/tests/orm.test.ts new file mode 100644 index 0000000..e5d6895 --- /dev/null +++ b/tests/orm.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { createDatabase, Database } from "../modules/database/src/index.ts"; +import { Field } from "../modules/database/src/decorators/Field.ts"; +import { Hydrate } from "../modules/database/src/decorators/Hydrate.ts"; +import { Table } from "../modules/database/src/decorators/Table.ts"; +import { encodeRow, hydrateRow } from "../modules/database/src/hydration.ts"; + +/** + * Tests of the ORM: the fluent repository, the hydration of the rows and the + * encoding of the values. + */ + +@Table("users") +class User { + @Field({ type: "text", primary: true }) + id: string; + + @Field({ + type: "text", + unique: true, + encode: (value: string) => value.trim().toLowerCase(), + }) + email: string; + + @Field({ + type: "text", + nullable: true, + encode: (value: string | null) => value?.trim() ?? null, + decode: (value: unknown) => (value === null ? null : String(value)), + }) + name: string | null; + + @Field({ type: "int", default: 0 }) + age: number; + + @Hydrate({ + decode: (row) => `${String(row.email).split("@")[1] ?? ""}`, + }) + domain: string; +} + +async function database(): Promise<Database> { + const db = createDatabase({ dialect: "sqlite", path: ":memory:" }); + db.model(User); + await db.migrate(); + return db; +} + +describe("orm", () => { + test("creates the table of a model", async () => { + const db = await database(); + const rows = await db + .model(User) + .query("SELECT name FROM sqlite_master WHERE type = 'table'"); + expect(rows).toHaveLength(1); + await db.close(); + }); + + test("inserts and reads a row", async () => { + const db = await database(); + const users = db.model(User); + + const created = await users.create({ + id: "1", + email: "ADA@Example.COM", + name: "Ada", + age: 36, + }); + expect(created.id).toBe("1"); + + const found = await users.where("id", "1").first(); + expect(found?.email).toBe("ada@example.com"); + expect(found?.name).toBe("Ada"); + await db.close(); + }); + + test("encodes the values before the commit", async () => { + const db = await database(); + const users = db.model(User); + + await users.create({ + id: "1", + email: "ada@example.com", + name: " Ada ", + age: 36, + }); + + const rows = await users.query( + "SELECT name, email FROM users WHERE id = '1'", + ); + expect(rows[0]?.name).toBe("Ada"); + expect(rows[0]?.email).toBe("ada@example.com"); + await db.close(); + }); + + test("hydrates the rows before they are read", async () => { + const db = await database(); + const users = db.model(User); + + await users.create({ + id: "1", + email: "ada@example.com", + name: null, + age: 36, + }); + + const found = await users.where("id", "1").first(); + expect(found?.domain).toBe("example.com"); + expect(found?.name).toBeNull(); + await db.close(); + }); + + test("queries with conditions, order and limit", async () => { + const db = await database(); + const users = db.model(User); + + for (const [index, name] of ["Ada", "Grace", "Alan"].entries()) { + await users.create({ + id: String(index), + email: `${name.toLowerCase()}@example.com`, + name, + age: 30 + index, + }); + } + + expect(await users.count()).toBe(3); + expect(await users.where("age", ">", 30).count()).toBe(2); + expect( + (await users.orderBy("age", "desc").limit(1).all())[0]?.name, + ).toBe("Alan"); + expect( + await users + .where([{ column: "name", operator: "=", value: "Ada" }]) + .count(), + ).toBe(1); + await db.close(); + }); + + test("updates and deletes rows", async () => { + const db = await database(); + const users = db.model(User); + + await users.create({ + id: "1", + email: "ada@example.com", + name: "Ada", + age: 36, + }); + + expect(await users.where("id", "1").update({ age: 37 })).toBe(1); + expect((await users.where("id", "1").first())?.age).toBe(37); + expect(await users.where("id", "1").delete()).toBe(1); + expect(await users.count()).toBe(0); + await db.close(); + }); + + test("runs a transaction", async () => { + const db = await database(); + const users = db.model(User); + + await db.transaction(async (tx) => { + await tx.model(User).create({ + id: "1", + email: "ada@example.com", + name: "Ada", + age: 36, + }); + }); + + expect(await users.count()).toBe(1); + await db.close(); + }); + + test("encodes and hydrates a row on its own", () => { + const encoded = encodeRow<User>(User as never, { + email: " A@B.COM ", + name: " x ", + }); + expect(encoded.email).toBe("a@b.com"); + expect(encoded.name).toBe("x"); + + const hydrated = hydrateRow<User>(User as never, { + id: "1", + email: "ada@example.com", + name: null, + age: 36, + }); + expect(hydrated.domain).toBe("example.com"); + expect(hydrated.name).toBeNull(); + }); +}); diff --git a/tests/postgres.test.ts b/tests/postgres.test.ts new file mode 100644 index 0000000..2b8524b --- /dev/null +++ b/tests/postgres.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { createDatabase, type Database } from "../modules/database/src/index.ts"; +import { Field } from "../modules/database/src/decorators/Field.ts"; +import { Hydrate } from "../modules/database/src/decorators/Hydrate.ts"; +import { Table } from "../modules/database/src/decorators/Table.ts"; + +/** + * Tests of the postgres adapter. + * + * The tests need a database, the connection string is read from + * `DATABASE_URL`. Without it the suite is skipped. + */ + +const url = process.env.DATABASE_URL; + +@Table("muddle_users") +class User { + @Field({ type: "text", primary: true }) + id: string; + + @Field({ + type: "text", + unique: true, + encode: (value: string) => value.trim().toLowerCase(), + }) + email: string; + + @Field({ + type: "text", + nullable: true, + encode: (value: string | null) => value?.trim() ?? null, + }) + name: string | null; + + @Field({ type: "int", default: 0 }) + age: number; + + @Field({ type: "bool", default: false }) + active: boolean; + + @Hydrate({ decode: (row) => String(row.email).split("@")[1] ?? "" }) + domain: string; +} + +async function database(): Promise<Database> { + const db = createDatabase({ dialect: "postgres", url }); + db.model(User); + await db.migrate(); + return db; +} + +describe.skipIf(!url)("postgres", () => { + test("creates the table of a model", async () => { + const db = await database(); + const rows = await db.model(User).query( + "SELECT table_name FROM information_schema.tables WHERE table_name = 'muddle_users'", + ); + expect(rows).toHaveLength(1); + await db.close(); + }); + + test("inserts, reads, updates and deletes", async () => { + const db = await database(); + const users = db.model(User); + await users.where("id", "1").delete(); + + await users.create({ + id: "1", + email: " ADA@Example.COM ", + name: " Ada ", + age: 36, + active: true, + }); + + const found = await users.where("id", "1").first(); + expect(found?.email).toBe("ada@example.com"); + expect(found?.name).toBe("Ada"); + expect(found?.active).toBe(true); + expect(found?.domain).toBe("example.com"); + + expect(await users.where("id", "1").update({ age: 37 })).toBe(1); + expect((await users.where("id", "1").first())?.age).toBe(37); + + expect(await users.where("id", "1").delete()).toBe(1); + await db.close(); + }); + + test("counts and filters rows", async () => { + const db = await database(); + const users = db.model(User); + await users.where("id", "2").delete(); + + await users.create({ + id: "2", + email: "grace@example.com", + name: "Grace", + age: 45, + }); + + expect(await users.count()).toBeGreaterThan(0); + expect(await users.where("age", ">", 40).count()).toBeGreaterThan(0); + await users.where("id", "2").delete(); + await db.close(); + }); +}); diff --git a/tests/prediction.test.ts b/tests/prediction.test.ts new file mode 100644 index 0000000..658304d --- /dev/null +++ b/tests/prediction.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from "bun:test"; +import { predictLayout } from "../adapters/prediction.ts"; + +test("prediction measures desktop and mobile geometry and restores closed disclosure", async () => { + const html = '<!doctype html><html><head><style>.panel { width: 400px; height: 180px; } @media(max-width:48rem) { .panel { width: 200px; height: 240px; } }</style></head><body><details><summary>Menu</summary><div class="panel" data-muddle-anchor="panel">Content</div></details></body></html>'; + const result = await predictLayout(html, { selectors: [".panel"], strict: true }); + expect(result.predictions.map((prediction) => prediction.width)).toEqual([400, 200]); + expect(result.predictions.map((prediction) => prediction.height)).toEqual([180, 240]); + expect(result.html).not.toContain("<details open"); + expect(result.html).toContain('data-muddle-predicted="predict-0"'); + expect(result.html).toContain("--muddle-predicted-desktop-width: 400px"); + expect(result.html).toContain("--muddle-predicted-mobile-width: 200px"); + expect(result.html).toContain("--muddle-relate-panel-desktop-width: 400px"); + expect(result.html).toContain("--muddle-relate-panel-mobile-width: 200px"); + expect(result.html).toContain("@media (max-width: 48rem)"); +}); + +test("prediction resolves a target that is sized from an ahead-of-time relation", async () => { + const html = `<!doctype html><html><head><style> + .source { position: absolute; left: 100px; top: 20px; width: 240px; height: 32px; } + .target { position: absolute; left: var(--muddle-relate-source-x); top: 80px; width: var(--muddle-relate-source-width); height: 24px; } + @media (max-width: 48rem) { + .source { left: 24px; width: 160px; } + } + </style></head><body> + <div class="source" data-muddle-anchor="source"></div> + <div class="target"></div> + </body></html>`; + const result = await predictLayout(html, { selectors: [".target"], strict: true }); + const target = result.predictions.filter((prediction) => prediction.selector === '[data-muddle-predicted="predict-0"]'); + expect(target.map((prediction) => prediction.width)).toEqual([240, 160]); + expect(target.map((prediction) => prediction.x)).toEqual([100, 24]); + expect(result.html).toContain("--muddle-predicted-desktop-width: 240px"); + expect(result.html).toContain("--muddle-predicted-mobile-width: 160px"); +}); + +test("prediction settles chained relations before writing the static page", async () => { + const html = `<!doctype html><html><head><style> + .source { position: absolute; left: 72px; top: 20px; width: 216px; height: 24px; } + .middle { position: absolute; left: var(--muddle-relate-source-x); top: 60px; width: var(--muddle-relate-source-width); height: 24px; } + .target { position: absolute; left: var(--muddle-relate-middle-x); top: 100px; width: var(--muddle-relate-middle-width); height: 24px; } + </style></head><body> + <div class="source" data-muddle-anchor="source"></div> + <div class="middle" data-muddle-anchor="middle"></div> + <div class="target"></div> + </body></html>`; + const result = await predictLayout(html, { selectors: [".target"], strict: true }); + const target = result.predictions.filter((prediction) => prediction.selector === '[data-muddle-predicted="predict-0"]'); + expect(target.map((prediction) => prediction.width)).toEqual([216, 216]); + expect(target.map((prediction) => prediction.x)).toEqual([72, 72]); +}); + +test("prediction follows the page's widest mobile breakpoint", async () => { + const html = '<!doctype html><html><head><style>.panel { width: 500px; height: 24px; } @media (max-width: 40rem) { .panel { width: 280px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>'; + const result = await predictLayout(html, { selectors: [".panel"], strict: true }); + expect(result.predictions.map((prediction) => prediction.width)).toEqual([500, 280]); + expect(result.html).toContain("@media (max-width: 40rem)"); +}); + +test("prediction emits each selected breakpointAcc relation layer", async () => { + const html = '<!doctype html><html><head><style>.panel { width: 600px; height: 24px; } @media (max-width: 64rem) { .panel { width: 420px; } } @media (max-width: 48rem) { .panel { width: 300px; } }</style></head><body><div class="panel" data-muddle-anchor="panel"></div></body></html>'; + const result = await predictLayout(html, { selectors: [".panel"], breakpointAcc: ["LG", "MD"], strict: true }); + expect(result.html).toContain("--muddle-relate-panel-lg-width: 420px"); + expect(result.html).toContain("--muddle-relate-panel-md-width: 300px"); + expect(result.html).toContain("@media (max-width: 64rem)"); + expect(result.html).toContain("@media (max-width: 48rem)"); +}); + +test("prediction discovers dotted anchor groups and emits reflective metadata", async () => { + const html = '<!doctype html><html><head><style>.item { width: 200px; }</style></head><body><a class="item" data-muddle-anchor="docs.intro"></a><a class="item" data-muddle-anchor="docs.api"></a></body></html>'; + const result = await predictLayout(html, { strict: true }); + expect(result.groups).toEqual([{ name: "docs", anchors: ["docs.intro", "docs.api"] }]); + expect(result.html).toContain('data-muddle-group="docs"'); + expect(result.html).toContain('--muddle-reflect-docs-count: 2'); +}); diff --git a/tests/router.test.ts b/tests/router.test.ts new file mode 100644 index 0000000..775c458 --- /dev/null +++ b/tests/router.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { App } from "../core/app.ts"; +import { Controller, Module, Root, ViewRoot } from "../decorators/index.ts"; +import { View } from "../decorators/View.ts"; +import { TemplateEngine } from "../views/engine/runtime.ts"; +import { Views } from "../views/api.ts"; + +/** + * Tests of the file based router: the relative views, the static roots of the + * controllers and the renderers that are applied inline. + */ + +const dir = await mkdtemp(join(tmpdir(), "muddle-router-")); + +await mkdir(join(dir, "views"), { recursive: true }); +await mkdir(join(dir, "public"), { recursive: true }); + +await writeFile( + join(dir, "views/index.spug"), + "props\n title: string\n\nh1= title", + "utf8", +); +await writeFile(join(dir, "views/about.spug"), "h2 About", "utf8"); +await writeFile( + join(dir, "views/card.spug"), + "props\n title: string\n content?: () => string | Promise<string>\n\narticle.card\n h3= title\n if content\n .card__content!= content()", + "utf8", +); +await writeFile(join(dir, "public/robots.txt"), "Allow: /", "utf8"); + +describe("file based router", () => { + test("resolves the views of the view root", async () => { + const engine = new TemplateEngine({ + viewsDir: join(dir, "views"), + root: dir, + }); + const html = await engine.render("index.spug", { title: "Hello" }); + expect(html).toBe("<h1>Hello</h1>"); + }); + + test("derives the route from the view", async () => { + const html = await engine("about.spug"); + expect(html).toBe("<h2>About</h2>"); + + async function engine(path: string): Promise<string> { + return new TemplateEngine({ + viewsDir: join(dir, "views"), + root: dir, + }).render(path); + } + }); + + test("renders a renderer that is applied inline", async () => { + const engine = new TemplateEngine({ + viewsDir: join(dir, "views"), + root: dir, + }); + const html = await engine.render("card.spug", { + title: "Card", + content: async () => "<p>inline</p>", + }); + expect(html).toContain("<h3>Card</h3>"); + expect(html).toContain('<div class="card__content"><p>inline</p></div>'); + }); + + test("serves the static root of a controller", async () => { + @Controller("/assets") + @Root(join(dir, "public")) + class AssetController {} + + @Controller("/") + @ViewRoot(join(dir, "views")) + class PageController { + @View("index.spug") + index() { + return { title: "Hello" }; + } + } + + @Module({ controllers: [AssetController, PageController] }) + class AppModule {} + + const app = await App.create({ + modules: [AppModule], + views: new Views({ viewsDir: join(dir, "views"), root: dir }), + }); + + const page = await app.fetch(new Request("http://localhost/")); + expect(page.status).toBe(200); + expect(await page.text()).toBe("<h1>Hello</h1>"); + + const asset = await app.fetch(new Request("http://localhost/assets/robots.txt")); + expect(asset.status).toBe(200); + expect(await asset.text()).toBe("Allow: /"); + + const missing = await app.fetch( + new Request("http://localhost/assets/nope.txt"), + ); + expect(missing.status).toBe(404); + + await rm(dir, { recursive: true, force: true }); + }); +}); diff --git a/tests/sessions-format.test.ts b/tests/sessions-format.test.ts new file mode 100644 index 0000000..05baf20 --- /dev/null +++ b/tests/sessions-format.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { DatabaseSessionStore } from "../modules/database/src/sessions.ts"; +import { createSqliteDriver } from "../modules/database/src/adapters/sqlite.ts"; +import { App } from "../core/app.ts"; +import { authIntegration } from "../integrations/auth.ts"; +import { Controller } from "../decorators/Controller.ts"; +import { Route } from "../decorators/Route.ts"; +import { Auth } from "../decorators/Auth.ts"; +import { Module } from "../decorators/Module.ts"; +import { resolveConfig } from "../core/config.ts"; +import { formatSource } from "../core/format-source.ts"; +import plugin from "../modules/prettier-plugin/index.ts"; +import prettier from "prettier"; +import { compileCss } from "../views/css/index.ts"; + +test("database sessions authenticate across app instances, revoke and consume atomically", async () => { + const driver = createSqliteDriver({ path: ":memory:" }); + const store = await DatabaseSessionStore.create(driver); + @Controller("/") class PrivateController { + @Route("/private") @Auth() private() { return "protected"; } + } + @Module({ controllers: [PrivateController] }) class Root {} + const create = () => App.create({ config: resolveConfig(), modules: [Root], integrations: [authIntegration({ store, login: () => ({ id: "ada" }) })] }); + try { + const first = await create(); + const second = await create(); + const login = await first.fetch(new Request("https://app.test/auth/login", { method: "POST", headers: { origin: "https://app.test", "content-type": "application/json" }, body: JSON.stringify({ email: "ada", password: "secret" }) })); + const cookie = login.headers.get("set-cookie")!; + expect((await second.fetch(new Request("https://app.test/private", { headers: { cookie } }))).status).toBe(200); + await first.fetch(new Request("https://app.test/auth/logout", { method: "POST", headers: { origin: "https://app.test", cookie } })); + expect((await second.fetch(new Request("https://app.test/private", { headers: { cookie } }))).status).toBe(401); + await store.set("transaction", { value: { verifier: "once" }, expires: Date.now() + 10000 }); + expect((await Promise.all([store.consume("transaction"), store.consume("transaction")])).filter(Boolean)).toHaveLength(1); + await store.set("expired", { value: {}, expires: 1 }); + expect(await store.get("expired")).toBeUndefined(); + } finally { await driver.close(); } +}); + +test("Prettier delegates to muddle formatting and preserves compiled STCSS", async () => { + const source = '.card{padding:1rem;@predict mobile max-height; &:hover{opacity:0.8;}}'; + const formatted = await formatSource(source, "stcss"); + expect(await prettier.format(source, { parser: "stcss", plugins: [plugin] })).toBe(formatted); + expect(await formatSource(formatted, "stcss")).toBe(formatted); + expect(compileCss(formatted)).toBe(compileCss(source)); + const spug = 'javascript!\nconst title={text:"Hello"}\nend\ndiv\n p= title.text\n'; + const next = await formatSource(spug, "spug"); + expect(next).toContain('const title = { text: "Hello" };'); + expect(await formatSource(next, "spug")).toBe(next); + const raw = 'script.\n const text = "a b";\n\n console.log(text);\np End\n'; + expect(await formatSource(raw, "spug")).toBe(raw); +}); diff --git a/tests/stage.test.ts b/tests/stage.test.ts new file mode 100644 index 0000000..c3f8c25 --- /dev/null +++ b/tests/stage.test.ts @@ -0,0 +1,22 @@ +import { expect, test } from "bun:test"; +import { compileTemplate } from "../views/engine/compiler.ts"; +import { lex } from "../views/engine/lexer.ts"; +import { parse } from "../views/engine/parser.ts"; +import { stage } from "../views/stage.ts"; + +test("stage manager emits native scroll-driven scenes and portal transitions", () => { + const css = stage() + .scene("intro-in", { selector: ".intro", keyframes: { "0%": { opacity: 0, transform: "translateY(2rem)" }, "100%": { opacity: 1, transform: "translateY(0)" } }, range: "entry 0% cover 45%" }) + .portal("main", { old: { opacity: 1 }, new: { opacity: 0 }, duration: "180ms" }) + .css(); + expect(css).toContain("animation-timeline: view()"); + expect(css).toContain("animation-range: entry 0% cover 45%"); + expect(css).toContain("::view-transition-old(main)"); +}); + +test("portal blocks compile to named view-transition boundaries", () => { + const parsed = parse(lex("portal main\n h1 Hello")); + const compiled = compileTemplate(parsed.nodes, { path: "/tmp/portal.spug", dir: "/tmp" }); + expect(compiled.source).toContain("data-muddle-portal=\\\"main\\\""); + expect(compiled.source).toContain("view-transition-name:main"); +}); diff --git a/tests/templates-generator.test.ts b/tests/templates-generator.test.ts new file mode 100644 index 0000000..97ddef2 --- /dev/null +++ b/tests/templates-generator.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTemplate } from "../core/templates.ts"; + +test("creates the responsive documentation template", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-docs-")); + try { + const result = await createTemplate("docs", root); + expect(result.files.length).toBeGreaterThan(6); + const view = await readFile(join(root, "modules/app/views/index.spug"), "utf8"); + const styles = await readFile(join(root, "styles/app.stcss"), "utf8"); + expect(view).toContain("@docs.{section.id}"); + expect(styles).toContain(":reflect(@docs)"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("creates the minimal template", async () => { + const root = await mkdtemp(join(tmpdir(), "muddle-minimal-")); + try { + await createTemplate("minimal", root); + expect(await readFile(join(root, "index.ts"), "utf8")).toContain("export const app"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/templates.test.ts b/tests/templates.test.ts new file mode 100644 index 0000000..f96ed52 --- /dev/null +++ b/tests/templates.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { join, resolve } from "node:path"; +import { runTemplateTests } from "../views/testing.ts"; +import { createViews } from "../views/api.ts"; +import { parse } from "../views/engine/parser.ts"; +import { lex } from "../views/engine/lexer.ts"; +import { compileTemplate } from "../views/engine/compiler.ts"; +import { registerAmezing } from "../modules/amezing/index.ts"; + +const root = resolve(import.meta.dir, ".."); +const sample = join(root, "samples/hello-world"); + +describe("template tests", () => { + test("SPUG anchors emit generated relation identities", () => { + const nodes = parse(lex("nav\n ul\n li@hello1() First\n li@row(item.id) Second")); + const firstAnchor = nodes.nodes[0]?.kind === "element" + ? nodes.nodes[0].children[0]?.kind === "element" + ? nodes.nodes[0].children[0].children[0] + : undefined + : undefined; + expect(firstAnchor).toMatchObject({ kind: "element", anchor: { name: "hello1" } }); + const compiled = compileTemplate(nodes.nodes, { path: "/tmp/relations.spug", dir: "/tmp" }); + expect(compiled.source).toContain('data-muddle-anchor'); + expect(compiled.source).toContain('"hello1"'); + expect(compiled.source).toContain('"row" + String(item.id)'); + expect(compiled.source).not.toContain("__muddleRelations"); + }); + + test("SPUG derives a dotted anchor leaf from composition scope", () => { + const nodes = parse(lex("a@docs.{section.id}(href='#' + section.id) Link")); + const compiled = compileTemplate(nodes.nodes, { path: "/tmp/reflective.spug", dir: "/tmp" }); + expect(compiled.source).toContain('"docs." + String(section.id)'); + }); + + test( + "the assertions of the templates pass", + async () => { + const views = createViews({ viewsDir: join(sample, "modules/app/views") }); + registerAmezing(views); + const run = await runTemplateTests({ + root: sample, + viewRoots: [join(sample, "modules/app/views")], + components: views.components, + locals: { + title: "muddle", + nav: [{ label: "Home", href: "/" }], + posts: [{ id: "test1", title: "Test1", excerpt: "Test1" }], + post: { id: "test1", title: "Test1", excerpt: "Test1" }, + }, + }); + + if (run.skipped) { + console.log(`muddle: template tests skipped, ${run.reason}`); + return; + } + + expect(run.tested.length).toBeGreaterThan(0); + for (const failure of run.failures) { + console.error(`muddle: ${failure.key}\n${failure.error.message}`); + } + expect(run.failures).toEqual([]); + }, + 30_000, + ); +}); diff --git a/tests/views.test.ts b/tests/views.test.ts new file mode 100644 index 0000000..59c34be --- /dev/null +++ b/tests/views.test.ts @@ -0,0 +1,195 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TemplateEngine } from "../views/engine/runtime.ts"; +import { lex } from "../views/engine/lexer.ts"; +import { parse } from "../views/engine/parser.ts"; + +/** + * Tests of the pug like template engine. + */ + +const dir = await mkdtemp(join(tmpdir(), "muddle-views-")); + +async function template(name: string, source: string): Promise<string> { + await writeFile(join(dir, `${name}.spug`), source, "utf8"); + return name; +} + +const engine = new TemplateEngine({ viewsDir: dir, root: dir }); + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +describe("lexer", () => { + test("reads tags, classes and attributes", () => { + const tokens = lex("a.card#top(href='/x' target='_blank') Link"); + const tag = tokens[0]; + expect(tag?.name).toBe("a"); + expect(tag?.attributes?.map((entry) => entry.name)).toEqual([ + "class", + "id", + "href", + "target", + ]); + expect(tag?.text).toBe("Link"); + }); + + test("reads a buffered expression", () => { + const tokens = lex("p= title"); + expect(tokens[0]?.output).toBe("title"); + }); + + test("reads an unbuffered expression", () => { + const tokens = lex("p!= title"); + expect(tokens[0]?.output).toBe("title"); + expect(tokens[0]?.escaped).toBe(false); + }); + + test("reads the frontmatter", () => { + const tokens = lex("---\nconst a = 1\n---\np Hello"); + expect(tokens[0]?.type).toBe("frontmatter"); + expect(tokens[0]?.value).toContain("const a = 1"); + }); + + test("reads a props block", () => { + const parsed = parse(lex("props\n title: string\n body?: string\np Hi")); + const props = parsed.nodes.find((node) => node.kind === "props"); + expect(props?.kind).toBe("props"); + if (props?.kind === "props") { + expect(props.props.map((prop) => prop.name)).toEqual([ + "title", + "body", + ]); + expect(props.props[0]?.optional).toBe(false); + expect(props.props[1]?.optional).toBe(true); + expect(props.props[1]?.type).toBe("string"); + } + }); +}); + +describe("renderer", () => { + test("renders a tag with a class", async () => { + const name = await template("class", "div.card Hello"); + expect(await engine.render(name)).toBe('<div class="card">Hello</div>'); + }); + + test("escapes the text", async () => { + const name = await template("escape", "p= value"); + expect(await engine.render(name, { value: "<b>" })).toBe( + "<p><b></p>", + ); + }); + + test("renders unescaped output", async () => { + const name = await template("raw", "p!= value"); + expect(await engine.render(name, { value: "<b>x</b>" })).toBe( + "<p><b>x</b></p>", + ); + }); + + test("renders a condition", async () => { + const name = await template( + "condition", + "if value\n p yes\nelse\n p no", + ); + expect(await engine.render(name, { value: true })).toBe("<p>yes</p>"); + expect(await engine.render(name, { value: false })).toBe("<p>no</p>"); + }); + + test("renders a loop", async () => { + const name = await template( + "loop", + "ul\n each item in items\n li= item", + ); + expect(await engine.render(name, { items: ["a", "b"] })).toBe( + "<ul><li>a</li><li>b</li></ul>", + ); + }); + + test("renders an attribute expression", async () => { + const name = await template( + "attribute", + "a(href=link.href class=link.active ? 'on' : 'off')= link.label", + ); + expect( + await engine.render(name, { + link: { href: "/x", active: true, label: "Link" }, + }), + ).toBe('<a href="/x" class="on">Link</a>'); + }); + + test("merges the classes of the shorthand and of the attribute", async () => { + const name = await template( + "classes", + "div.card(class=extra) Text", + ); + expect(await engine.render(name, { extra: "wide" })).toBe( + '<div class="card wide">Text</div>', + ); + }); + + test("renders a mixin", async () => { + const name = await template( + "mixin", + "mixin Item({ text })\n li= text\nul\n +Item({ text: 'a' })", + ); + expect(await engine.render(name)).toBe("<ul><li>a</li></ul>"); + }); + + test("renders an included partial", async () => { + await template("partial", "footer Footer"); + const name = await template( + "include", + "div\n include ./partial.spug", + ); + expect(await engine.render(name)).toBe("<div><footer>Footer</footer></div>"); + }); + + test("renders a layout with a block", async () => { + await template( + "layout", + "html\n body\n block content\n footer Footer", + ); + const name = await template( + "page", + "extends ./layout.spug\nblock content\n p Page", + ); + expect(await engine.render(name)).toBe( + "<html><body><p>Page</p><footer>Footer</footer></body></html>", + ); + }); + + test("renders the frontmatter of a layout", async () => { + await template( + "front-layout", + "---\nconst greeting = 'Hello'\n---\np= greeting", + ); + expect(await engine.render("front-layout")).toBe("<p>Hello</p>"); + }); + + test("renders a component of the components directory", async () => { + const components = join(dir, "components"); + await mkdir(components, { recursive: true }); + await writeFile( + join(components, "card.spug"), + "article.card\n h3= title", + "utf8", + ); + const scoped = new TemplateEngine({ + viewsDir: dir, + componentsDir: components, + root: dir, + }); + expect( + await scoped.renderComponent("card", { title: "Card" }), + ).toBe('<article class="card"><h3>Card</h3></article>'); + }); + + test("keeps the html of the optional props empty", async () => { + const name = await template("optional", "p= missing"); + expect(await engine.render(name)).toBe("<p></p>"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..ba61408 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,49 @@ +{ + "exclude": ["node_modules", "shadcn-svelte"], + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "paths": { + "muddle/formatter": ["./core/format-source.ts"], + "muddle/sessions": ["./core/sessions.ts"], + "muddle/contracts": ["./contracts/index.ts"], + "muddle/contracts/*": ["./contracts/*"], + "muddle/metadata": ["./core/metadata.ts"], + "muddle/markdown": ["./views/markdown.ts"], + "muddle/views": ["./views/api.ts"], + "muddle/stcss": ["./views/css/index.ts"], + "muddle": ["./index.ts"], + "@muddle/database": ["./modules/database/index.ts"], + "@muddle/shadcn": ["./modules/shadcn/index.ts"], + "@muddle/*": ["./*"] + }, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + + // Best practices + "strict": true, + "strictPropertyInitialization": false, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +} diff --git a/views/api.ts b/views/api.ts new file mode 100644 index 0000000..a9fcb3c --- /dev/null +++ b/views/api.ts @@ -0,0 +1,303 @@ +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import type { ViewDescriptor } from "../core/router.ts"; +import type { RequestContext } from "../contracts/typegen/http/request.ts"; +import type { RenderedView } from "../contracts/typegen/http/response.ts"; +import type { ViewPath, ViewPropsOf } from "../contracts/typegen/views.ts"; +import type { DeferredChunk } from "../contracts/typegen/http/response.ts"; +import { TemplateEngine, type RenderContext } from "./engine/runtime.ts"; +import { compileCss, styleTag } from "./css/index.ts"; +import type { TemplateContext } from "../core/context.ts"; +import type { + ComponentDefinition, + ComponentRegistry, + ServerComponent, +} from "./components.ts"; +import type { MuddleHooks } from "./hooks.ts"; + +/** + * Fluent, type generated view api. + * + * The props of a template are declared inside the template itself, the + * generator turns them into a contract and the builder only accepts the props + * of the selected template. + */ + +export interface ViewsOptions { + context?: TemplateContext; + /** Directory holding the templates. */ + viewsDir: string; + /** Root directory of the project, template paths are resolved against it. */ + root?: string; + /** Directory holding the components, defaults to `viewsDir/components`. */ + componentsDir?: string; + /** Server components registered on creation. */ + components?: ComponentRegistry; + globalLayouts?: string[]; + /** Contextual hooks shared by Spug rendering and STCSS component styles. */ + hooks?: MuddleHooks; +} + +export interface SpugComponentOptions { + template?: string; + style?: string | string[]; + deferred?: boolean; +} + +/** Options of a single render. */ +export interface RenderOptions { + /** Route parameters handed to the template. */ + params?: Record<string, string>; + /** Additional locals handed to the template. */ + locals?: Record<string, unknown>; +} + +/** Result of a render. */ +export interface RenderResult { + /** Html of the shell. */ + html: string; + /** Chunks rendered after the shell. */ + deferred: DeferredChunk[]; +} + +export class ViewBuilder<P extends ViewPath = ViewPath> { + private readonly state: { + props: Record<string, unknown>; + params: Record<string, string>; + locals: Record<string, unknown>; + }; + + constructor( + private readonly views: Views, + private readonly path: P, + props: Record<string, unknown> = {}, + params: Record<string, string> = {}, + locals: Record<string, unknown> = {}, + ) { + this.state = { + props: { ...props }, + params: { ...params }, + locals: { ...locals }, + }; + } + + /** Sets the props of the template. */ + with(props: ViewPropsOf<P>): this { + Object.assign(this.state.props, props); + return this; + } + + /** Sets the route parameters handed to the template. */ + params(params: Record<string, string>): this { + Object.assign(this.state.params, params); + return this; + } + + /** Sets additional locals handed to the template. */ + locals(locals: Record<string, unknown>): this { + Object.assign(this.state.locals, locals); + return this; + } + + /** Renders the template and awaits the deferred components. */ + async render(): Promise<string> { + const result = await this.renderResult(); + const deferred = await Promise.all( + result.deferred.map((chunk) => chunk.content), + ); + if (deferred.length === 0) return result.html; + const appended = deferred + .map( + (html, index) => + `<div data-muddle-deferred="${result.deferred[index]?.id ?? index}">${html}</div>`, + ) + .join(""); + return `${result.html}${appended}`; + } + + /** Renders the template and returns the deferred chunks separately. */ + async renderResult(): Promise<RenderResult> { + const context: RenderContext = { + locals: { ...this.state.locals, ...this.state.props }, + params: this.state.params, + deferred: [], + }; + const html = await this.views.engine.render( + this.path, + context.locals, + context, + ); + return { html, deferred: context.deferred }; + } + + /** + * Renders the template as a stream. + * + * The shell is sent first, the deferred components follow as soon as they + * are rendered. No javascript is required, the browser renders the chunks + * while they arrive. + */ + async renderStream(): Promise<ReadableStream<Uint8Array>> { + const { html, deferred } = await this.renderResult(); + const encoder = new TextEncoder(); + return new ReadableStream<Uint8Array>({ + async start(controller) { + controller.enqueue(encoder.encode(html)); + for (const chunk of deferred) { + const content = await chunk.content; + controller.enqueue( + encoder.encode( + `<div data-muddle-deferred="${chunk.id}">${content}</div>`, + ), + ); + } + controller.close(); + }, + }); + } +} + +export class Views { + readonly engine: TemplateEngine; + readonly components: ComponentRegistry; + private readonly hooks?: MuddleHooks; + + constructor(options: ViewsOptions) { + this.hooks = options.hooks; + this.components = options.components ?? new Map(); + for (const [name, component] of options.context?.imports ?? []) this.components.set(name, { name, component: component as ServerComponent, deferred: false }); + this.engine = new TemplateEngine({ + viewsDir: options.viewsDir, + componentsDir: options.componentsDir, + root: options.root, + components: this.components, + globalLayouts: options.globalLayouts, + hooks: options.hooks, + }); + } + + /** Registers a server component. */ + component( + name: string, + component: ServerComponent, + options: { deferred?: boolean } = {}, + ): this { + const definition: ComponentDefinition = { + name, + component, + deferred: options.deferred === true, + }; + this.components.set(name, definition); + return this; + } + + /** Registers a component rendered by a SPUG template and optional STCSS files. */ + spugComponent(name: string, options: SpugComponentOptions = {}): this { + const template = options.template ?? name; + const styles = Array.isArray(options.style) ? options.style : options.style ? [options.style] : []; + return this.component(name, { + render: async (props) => { + const html = await this.engine.renderComponent(template, props); + const css = await Promise.all(styles.map(async (style) => { + const path = resolve(this.componentsDir, style); + return existsSync(path) ? compileCss(await readFile(path, "utf8"), { hooks: this.hooks }) : ""; + })); + return `${css.filter(Boolean).map(styleTag).join("")}${html}`; + }, + }, { deferred: options.deferred }); + } + + /** Starts a fluent render of a template. */ + view<P extends ViewPath>(path: P): ViewBuilder<P> { + return new ViewBuilder<P>(this, path); + } + + /** Renders a template with the given props. */ + async render<P extends ViewPath>( + path: P, + props: ViewPropsOf<P>, + options: RenderOptions = {}, + ): Promise<string> { + const builder = this.view(path).with(props); + if (options.params) builder.params(options.params); + if (options.locals) builder.locals(options.locals); + return builder.render(); + } + + /** + * Renders a template by its path on disk. + * + * Used by the `@View()` decorator, therefore the path is resolved relative + * to the project. + */ + async renderFile( + path: string, + props: Record<string, unknown> = {}, + options: RenderOptions = {}, + ): Promise<string> { + const context: RenderContext = { + locals: { ...options.locals, ...props }, + params: options.params ?? {}, + deferred: [], + }; + const html = await this.engine.render(path, context.locals, context); + const deferred = await Promise.all( + context.deferred.map((chunk) => chunk.content), + ); + if (deferred.length === 0) return html; + const appended = deferred + .map( + (html, index) => + `<div data-muddle-deferred="${context.deferred[index]?.id ?? index}">${html}</div>`, + ) + .join(""); + return `${html}${appended}`; + } + + /** + * Renders a view descriptor of a route. + * + * The descriptor of a `@View()` decorator carries an absolute path, the + * descriptor of a controller carries the key of a view. + */ + async renderDescriptor( + descriptor: ViewDescriptor, + context: RequestContext, + ): Promise<string | RenderedView> { + const props = (descriptor.props ?? {}) as Record<string, unknown>; + if (isAbsolute(descriptor.template)) { + const html = await this.renderFile(descriptor.template, props, { + params: context.params, + locals: { ...context.locals }, + }); + return html; + } + const builder = this.view( + descriptor.template as Parameters<Views["view"]>[0], + ).with(props as never); + builder.params(context.params); + builder.locals(context.locals); + return builder.renderResult(); + } + + /** Directory of the templates. */ + get viewsDir(): string { + return this.engine.viewsDir; + } + + /** Directory of the components. */ + get componentsDir(): string { + return this.engine.componentsDir; + } +} + +/** Creates the views of a project. */ +export function createViews(options: ViewsOptions): Views { + return new Views(options); +} + +/** Resolves a template path relative to the views directory. */ +export function resolveTemplatePath(viewsDir: string, path: string): string { + return join(resolve(viewsDir), dirname(path), path); +} diff --git a/views/components.ts b/views/components.ts new file mode 100644 index 0000000..3cecd4f --- /dev/null +++ b/views/components.ts @@ -0,0 +1,79 @@ +import type { ComponentMetadata } from "../decorators/Component.ts"; + +/** + * Server components. + * + * A server component is a class that renders html on the server. It behaves + * like a template, but it is written in TypeScript, may inject services and + * may be asynchronous. + */ + +/** Context handed to a server component. */ +export interface ComponentContext { + /** + * Defers a chunk of html until the end of the response. + * + * The response is streamed, therefore the shell reaches the browser + * before the deferred content is rendered. + */ + defer(id: string, content: string | Promise<string>): void; + /** Locals of the current request. */ + locals: Record<string, unknown>; + /** Route parameters of the current request. */ + params: Record<string, string>; +} + +/** Declarative output from a decorated server component. */ +export interface SpugComponentRender { + /** Component template name or path, resolved as SPUG. */ + spug: string; + /** STCSS source or a stylesheet path relative to the component directory. */ + stcss?: string | string[]; + /** Props merged with the invocation props before rendering the SPUG template. */ + props?: Record<string, unknown>; +} + +/** Contract of a server component. */ +export interface ServerComponent { + /** Renders the component. */ + render( + props: Record<string, unknown>, + context: ComponentContext, + ): Promise<string | SpugComponentRender> | string | SpugComponentRender; +} + +export function isSpugComponentRender(value: unknown): value is SpugComponentRender { + return typeof value === "object" && value !== null && typeof (value as { spug?: unknown }).spug === "string"; +} + +/** A registered server component. */ +export interface ComponentDefinition { + /** Name used inside the templates. */ + name: string; + component: ServerComponent; + /** `true` when the component is rendered after the shell. */ + deferred: boolean; + description?: string; + params?: Record<string, string>; + /** Decorated components must return a SPUG and STCSS composition. */ + requiresSpug?: boolean; +} + +/** Registry of the server components of an application. */ +export type ComponentRegistry = Map<string, ComponentDefinition>; + +/** Builds a component definition from a decorated class. */ +export function defineComponent( + name: string, + component: ServerComponent, + metadata?: ComponentMetadata, +): ComponentDefinition { + return { + name, + component, + deferred: metadata?.deferred === true, + description: metadata?.description, + params: metadata?.params, + requiresSpug: metadata !== undefined, + }; +} diff --git a/views/css/compiler.ts b/views/css/compiler.ts new file mode 100644 index 0000000..0112243 --- /dev/null +++ b/views/css/compiler.ts @@ -0,0 +1,427 @@ +import { builtinFunctions, evaluate, type Scope } from "./evaluate.ts"; +import { + parseCss, + type CssAtRule, + type CssMixin, + type CssNode, +} from "./parser.ts"; +import { createHooks, type MuddleHooks } from "../hooks.ts"; + +export interface CompileOptions { + /** Variables added to the stylesheet, e.g. from the theme. */ + variables?: Record<string, string>; + /** Named viewport breakpoints used by `@responsive`. */ + breakpoints?: Record<string, string>; + /** Receives the `@measure` prediction targets of the stylesheet. */ + measured?: string[]; + /** Contextual STCSS expansions, including optional application-defined hooks. */ + hooks?: MuddleHooks; +} + +interface Context { + scope: Scope; + mixins: Map<string, CssMixin>; + breakpoints: Record<string, string>; + measured: string[]; + hooks: MuddleHooks; +} + +/** Compiles a stylesheet into plain css. */ +export function compileCss( + source: string, + options: CompileOptions = {}, +): string { + const scripted = source.replace(/(?:javascript|typescript)!\s*\n([\s\S]*?)\nend\s*/g, (_match, code: string) => { + for (const entry of code.matchAll(/(?:const|let)\s+([\w-]+)\s*=\s*([^;\n]+);?/g)) { + options.variables ??= {}; + options.variables[`$${entry[1]}`] = String(entry[2]).replace(/^['"]|['"]$/g, ""); + } + return ""; + }); + const stylesheet = parseCss(scripted); + const context: Context = { + scope: { ...(options.variables ?? {}) }, + mixins: new Map(stylesheet.mixins.map((mixin) => [mixin.name, mixin])), + breakpoints: options.breakpoints ?? {}, + measured: options.measured ?? [], + hooks: options.hooks ?? createHooks(), + }; + + for (const variable of stylesheet.variables) { + context.scope[variable.name] = resolveValue(variable.value, context); + } + + const compiled = compileNodes(stylesheet.rules, "", context, 0); + return context.measured.length > 0 + ? `/* muddle-predict: ${context.measured.join(" | ")} */\n${compiled}` + : compiled; +} + +function compileNodes( + nodes: CssNode[], + selector: string, + context: Context, + depth: number, +): string { + const output: string[] = []; + + for (const node of nodes) { + if (node.kind === "rule") { + const resolved = resolveSelector( + resolveValue(node.selector, context), + selector, + ); + const declarationInput = node.declarations.map((declaration) => ({ ...declaration })); + const expansions = context.hooks.expandStcssDeclarations({ + selector: resolved, + declarations: declarationInput, + depth, + resolve: (value) => resolveValue(value, context), + }); + const declarations = declarationInput.map((declaration) => + `${declaration.property}: ${resolveValue(declaration.value, context)};`, + ); + const nested = compileNodes(node.children, resolved, context, depth + 1); + + const indent = "\t".repeat(depth); + if (resolved.length === 0) { + for (const declaration of declarations) { + output.push(`${indent}${declaration}`); + } + if (expansions.length > 0) output.push(...expansions); + if (nested.length > 0) output.push(nested); + continue; + } + if (declarations.length > 0) { + output.push(`${indent}${resolved} {`); + for (const declaration of declarations) { + output.push(`${indent}\t${declaration}`); + } + output.push(`${indent}}`); + } + if (expansions.length > 0) { + if (declarations.length > 0) output.push(""); + output.push(...expansions); + } + if (nested.length > 0) { + if (declarations.length > 0 || expansions.length > 0) output.push(""); + output.push(nested); + } + continue; + } + + output.push(compileAtRule(node, selector, context, depth)); + } + + return output.filter((entry) => entry.length > 0).join("\n"); +} + +function compileAtRule( + node: CssAtRule, + selector: string, + context: Context, + depth: number, +): string { + const indent = "\t".repeat(depth); + const expanded = context.hooks.expandStcssAtRule({ + name: node.name, + params: node.params, + selector, + depth, + children: node.children, + resolve: (value) => resolveValue(value, context), + renderChildren: () => compileNodes(node.children, selector, context, depth + 1), + }); + if (expanded !== undefined) return expanded; + + switch (node.name) { + case "each": { + const match = /^\$([\w-]+)\s+in\s+(.+)$/.exec(node.params); + if (!match) return ""; + const name = `$${match[1] as string}`; + const items = splitValues(match[2] as string); + const output: string[] = []; + for (const item of items) { + const scope = { ...context.scope }; + scope[name] = resolveValue(item, context); + const nested = compileNodes( + node.children, + selector, + { ...context, scope }, + depth, + ); + if (nested.length > 0) output.push(nested); + } + return output.join("\n"); + } + case "for": { + const match = /^\$([\w-]+)\s+from\s+([\d.]+)\s+through\s+([\d.]+)$/.exec( + node.params, + ); + if (!match) return ""; + const name = `$${match[1] as string}`; + const from = Number(match[2]); + const to = Number(match[3]); + const output: string[] = []; + for (let value = from; value <= to; value += 1) { + const scope = { ...context.scope }; + scope[name] = String(value); + const nested = compileNodes( + node.children, + selector, + { ...context, scope }, + depth, + ); + if (nested.length > 0) output.push(nested); + } + return output.join("\n"); + } + case "include": { + const match = /^([\w-]+)\s*(?:\(([\s\S]*)\))?$/.exec(node.params.trim()); + if (!match) return ""; + const mixin = context.mixins.get(match[1] as string); + if (!mixin) { + throw new Error(`Unknown mixin "${match[1] as string}"`); + } + const arguments_ = splitList(match[2] ?? ""); + const scope = { ...context.scope }; + mixin.params.forEach((param, index) => { + const value = arguments_[index] ?? param.value; + if (value !== undefined) { + scope[param.name] = resolveValue(value, context); + } + }); + return compileNodes( + mixin.children, + selector, + { ...context, scope }, + depth, + ); + } + case "view-transition": { + const name = node.params.trim() || "none"; + return selector ? `${indent}${selector} {\n${indent}\tview-transition-name: ${name};\n${indent}}` : ""; + } + case "predict": { + const mobile = /\bmobile\b/.test(node.params); + const properties = node.params.replace(/\bmobile\b/g, "").trim() || "transform, max-width, max-height"; + const transitions = properties.split(/[\s,]+/).filter(Boolean).map((property) => `${property} var(--muddle-predict-duration, 280ms) var(--muddle-predict-easing, ease)`).join(", "); + const css = selector ? `${indent}${selector} {\n${indent}\ttransition: ${transitions};\n${indent}}` : ""; + return mobile ? `@media (max-width: 48rem) {\n${css}\n}` : css; + } + case "measure": { + const spec = node.params.trim(); + const candidates = spec.length > 0 ? splitList(spec) : [selector]; + for (const candidate of candidates) { + if (candidate.length === 0) continue; + const resolved = resolveValue(candidate, context); + const related = resolveRelationSelector(resolved).replace(/@([\w.-]+)/g, (_all, name: string) => `[data-muddle-anchor="${name}"]`); + context.measured.push(related); + } + return ""; + } + case "media": + case "supports": { + const nested = compileNodes(node.children, selector, context, depth + 1); + if (nested.length === 0) return ""; + return `${indent}@${node.name} ${node.params} {\n${nested}\n${indent}}`; + } + case "responsive": { + const width = context.breakpoints[node.params.trim()]; + if (!width) throw new Error(`Unknown responsive breakpoint "${node.params.trim()}"`); + const nested = compileNodes(node.children, selector, context, depth + 1); + return nested ? `${indent}@media (min-width: ${width}) {\n${nested}\n${indent}}` : ""; + } + default: + return ""; + } +} + + +/** Resolves the selector of a nested rule. */ +export function resolveSelector(selector: string, parent: string): string { + selector = resolveRelationSelector(selector); + const parts = selector + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0) + .map((part) => { + if (part.includes("&")) { + return part.replace(/&/g, parent); + } + return parent ? `${parent} ${part}` : part; + }); + return parts.join(", "); +} + +/** Resolves the variables, the interpolations and the functions of a value. */ +export function resolveValue(value: string, context: Context): string { + let result = value; + + result = result.replace(/#\{([^}]*)\}/g, (_match, expression: string) => { + return evaluate(expression, { scope: context.scope }); + }); + result = result.replace(/d(["'])(.*?)\1/g, (_match, quote: string, name: string) => { + const resolved = name.replace(/\{([^}]*)\}/g, (_entry: string, expression: string) => evaluate(expression, { scope: context.scope })); + return `d${quote}${resolved}${quote}`; + }); + + result = result.replace(/\$[\w-]+/g, (match) => { + const resolved = context.scope[match]; + return resolved ?? match; + }); + + result = replaceFunctions(result, context); + result = resolveRelationValue(result); + + return result.replace(/\s+/g, " ").trim(); +} + +function relationKey(name: string): string { + return name.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +function relationState(name: string, state: string): string { + const anchor = `[data-muddle-anchor="${name}"]`; + if (state === "clicked") return `${anchor}:active`; + if (state === "active") return `${anchor}:active`; + if (state === "focus") return `${anchor}:focus-visible`; + return `${anchor}[data-muddle-${state}]`; +} + +/** Turns relation pseudo selectors into native relational selectors. */ +function resolveRelationSelector(selector: string): string { + let result = selector; + result = result.replace(/:reflect\(\s*@([\w.-]+)\s*\)/g, (_all, group: string) => + `[data-muddle-anchor^="${group}."]`, + ); + result = result.replace(/:with\(\s*@([\w.-]+):([\w-]+)\s*\)/g, (_all, name: string, state: string) => + `:has(${relationState(name, state)})`, + ); + result = result.replace(/([^,{]+?):when\(\s*([^\s()]+)\s+@([\w.-]+):([\w-]+)\s*\)/g, (_all, target: string, root: string, name: string, state: string) => { + const normalized = target.trim(); + const suffix = normalized.startsWith(root) + ? normalized.slice(root.length).trim() + : normalized; + return `${root}:has(${relationState(name, state)})${suffix ? ` ${suffix}` : ""}`; + }); + return result.replace(/@([\w.-]+):(active|clicked|focus)\b/g, (_all, name: string, state: string) => relationState(name, state)); +} + +/** Resolves precomputed anchor metrics and live computed-style mirrors. */ +function resolveRelationValue(value: string): string { + const self = value.replace(/\$self\[([a-zA-Z][\w-]*)\]/g, (_all, property: string) => { + return `var(--muddle-self-${relationKey(property)})`; + }); + + let result = self.replace(/\$(?:relate|refer)(?:\[([\w-]+)\])?\(\s*d?["']?@([^\s)"']+)["']?\s*\)/g, (_all, field: string | undefined, name: string) => { + const metric = ({ left: "x", top: "y" } as Record<string, string>)[field ?? ""] ?? field ?? "x"; + return `var(--muddle-relate-${relationKey(name)}-${metric})`; + }); + result = result.replace(/@([A-Za-z_][\w.-]*)\.computed\.([\w-]+)(?:\.replicate\(&\))?/g, (_all, name: string, property: string) => { + const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + return `var(--muddle-relate-${relationKey(name)}-${cssProperty})`; + }); + result = result.replace(/@([A-Za-z_][\w-]*)\.([\w-]+)(?:\.replicate\(&\))?/g, (_all, name: string, property: string) => { + const cssProperty = property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); + return `var(--muddle-relate-${relationKey(name)}-${cssProperty})`; + }); + return result; +} + +function replaceFunctions(value: string, context: Context): string { + let result = value; + let index = result.indexOf("("); + + while (index !== -1) { + const nameMatch = /(\$?[\w-]+)\s*$/.exec(result.slice(0, index)); + if (!nameMatch) { + index = result.indexOf("(", index + 1); + continue; + } + const name = nameMatch[1] as string; + const depth = matchParens(result, index); + if (depth === -1) break; + const inner = result.slice(index + 1, depth); + if (name.startsWith("$")) { + const expandedCalculation = context.hooks.expandStcssCalculation(name.slice(1), inner, (entry) => resolveValue(entry, context)); + if (expandedCalculation !== undefined) { + result = `${result.slice(0, index - name.length)}${expandedCalculation}${result.slice(depth + 1)}`; + index = result.indexOf("(", index + 1); + continue; + } + } + const expanded = context.hooks.expandStcssValue(name, inner, (value) => resolveValue(value, context)); + if (expanded !== undefined) { + result = `${result.slice(0, index - name.length)}${expanded}${result.slice(depth + 1)}`; + index = result.indexOf("(", index + 1); + continue; + } + if (!builtinFunctions[name]) { + // unknown functions are css functions, they stay untouched + index = result.indexOf("(", index + 1); + continue; + } + const evaluated = evaluate(`${name}(${inner})`, { scope: context.scope }); + result = `${result.slice(0, index - name.length)}${evaluated}${result.slice(depth + 1)}`; + index = result.indexOf("(", index + 1); + } + + return context.hooks.expandStcssCalculationTokens(result, (entry) => resolveValue(entry, context)); +} + +function matchParens(source: string, start: number): number { + let depth = 0; + for (let index = start; index < source.length; index++) { + const char = source[index]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +/** + * Splits a list of values. + * + * The values are separated by commas or by spaces, e.g. `1 2 3` and + * `red, green, blue`. + */ +function splitValues(value: string): string[] { + const range = /^\[\s*(-?\d+)\s*\.\.\s*(-?\d+)\s*\]$/.exec(value.trim()); + if (range) { + const from = Number(range[1]); + const to = Number(range[2]); + const step = from <= to ? 1 : -1; + const values: string[] = []; + for (let current = from; step > 0 ? current <= to : current >= to; current += step) values.push(String(current)); + return values; + } + const items: string[] = []; + for (const part of splitList(value)) { + for (const entry of part.split(/\s+/)) { + if (entry.length > 0) items.push(entry); + } + } + return items; +} + +function splitList(value: string): string[] { + const items: string[] = []; + let depth = 0; + let current = ""; + for (const char of value) { + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (char === "," && depth === 0) { + items.push(current.trim()); + current = ""; + continue; + } + current += char; + } + if (current.trim().length > 0) items.push(current.trim()); + return items; +} diff --git a/views/css/evaluate.ts b/views/css/evaluate.ts new file mode 100644 index 0000000..862fe5a --- /dev/null +++ b/views/css/evaluate.ts @@ -0,0 +1,318 @@ +/** + * Expression evaluation of the structured css. + * + * The evaluator understands numbers, strings, variables, arithmetic and a + * small set of colour functions. It is used by the interpolations + * (`#{$gap * 2}`) and by the functions of the stylesheets. + */ + +export type Scope = Record<string, string>; + +export interface EvaluateOptions { + /** Variables of the current scope. */ + scope?: Scope; + /** Names of the mixins that can be called. */ + functions?: Record<string, (...args: string[]) => string>; +} + +type Token = + | { type: "number"; value: number } + | { type: "string"; value: string } + | { type: "variable"; value: string } + | { type: "operator"; value: string } + | { type: "function"; value: string } + | { type: "punctuation"; value: string }; + +const OPERATORS = ["+", "-", "*", "/", "(", ")", ","]; + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + + while (index < source.length) { + const char = source[index] as string; + + if (/\s/.test(char)) { + index += 1; + continue; + } + + if (char === "$") { + const match = /^\$[\w-]+/.exec(source.slice(index)); + if (!match) throw new Error(`Invalid variable in "${source}"`); + tokens.push({ type: "variable", value: match[0] }); + index += match[0].length; + continue; + } + + if (char === "#") { + const match = /^#[\da-fA-F]{3,8}/.exec(source.slice(index)); + if (match) { + tokens.push({ type: "string", value: match[0] }); + index += match[0].length; + continue; + } + } + + if (/[\d.]/.test(char)) { + const match = /^[\d.]+/.exec(source.slice(index)); + if (!match) throw new Error(`Invalid number in "${source}"`); + tokens.push({ type: "number", value: Number(match[0]) }); + index += match[0].length; + continue; + } + + if (/[a-zA-Z_]/.test(char)) { + const match = /^[a-zA-Z_][\w-]*/.exec(source.slice(index)); + if (!match) throw new Error(`Invalid identifier in "${source}"`); + const word = match[0]; + let cursor = index + word.length; + while (cursor < source.length && /\s/.test(source[cursor] as string)) { + cursor += 1; + } + if (source[cursor] === "(") { + tokens.push({ type: "function", value: word }); + index = cursor; + continue; + } + tokens.push({ type: "string", value: word }); + index += word.length; + continue; + } + + if (char === '"' || char === "'") { + const end = source.indexOf(char, index + 1); + const value = + end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); + tokens.push({ type: "string", value }); + index = end === -1 ? source.length : end + 1; + continue; + } + + const operator = OPERATORS.find((candidate) => + source.startsWith(candidate, index), + ); + if (operator) { + tokens.push({ + type: + operator === "(" || operator === ")" || operator === "," + ? "punctuation" + : "operator", + value: operator, + }); + index += operator.length; + continue; + } + + throw new Error(`Unexpected character "${char}" in "${source}"`); + } + + return tokens; +} + +class Parser { + private index = 0; + + constructor( + private readonly tokens: Token[], + private readonly options: EvaluateOptions, + ) {} + + parse(): string { + const value = this.expression(); + if (this.index < this.tokens.length) { + throw new Error("Unexpected token in expression"); + } + return value; + } + + private peek(): Token | undefined { + return this.tokens[this.index]; + } + + private expression(): string { + let left = this.term(); + while (this.peek()?.type === "operator") { + const operator = (this.peek() as Token).value; + if (operator !== "+" && operator !== "-") break; + this.index += 1; + const right = this.term(); + left = operator === "+" ? add(left, right) : subtract(left, right); + } + return left; + } + + private term(): string { + let left = this.factor(); + while (this.peek()?.type === "operator") { + const operator = (this.peek() as Token).value; + if (operator !== "*" && operator !== "/") break; + this.index += 1; + const right = this.factor(); + left = operator === "*" ? multiply(left, right) : divide(left, right); + } + return left; + } + + private factor(): string { + const token = this.peek(); + if (!token) throw new Error("Unexpected end of expression"); + this.index += 1; + + if (token.type === "number") return String(token.value); + if (token.type === "string") { + if (token.value.startsWith("#")) return token.value; + return token.value; + } + if (token.type === "variable") { + const value = this.options.scope?.[token.value]; + if (value === undefined) { + throw new Error(`Unknown variable ${token.value}`); + } + return value; + } + if (token.type === "punctuation" && token.value === "(") { + const value = this.expression(); + const closing = this.peek(); + if (closing?.type === "punctuation" && closing.value === ")") { + this.index += 1; + } + return value; + } + if (token.type === "function") { + const args: string[] = []; + const opening = this.peek(); + if (opening?.type === "punctuation" && opening.value === "(") { + this.index += 1; + while (this.index < this.tokens.length) { + const current = this.peek() as Token; + if (current.type === "punctuation" && current.value === ")") { + this.index += 1; + break; + } + if (current.type === "punctuation" && current.value === ",") { + this.index += 1; + continue; + } + args.push(this.expression()); + } + } + const fn = this.options.functions?.[token.value]; + if (!fn) throw new Error(`Unknown function ${token.value}`); + return fn(...args); + } + + throw new Error(`Unexpected token ${token.value}`); + } +} + +function toNumber(value: string): number { + const numeric = Number(value); + return Number.isNaN(numeric) ? 0 : numeric; +} + +function add(left: string, right: string): string { + if (left.startsWith("#") || right.startsWith("#")) { + return `${left}${right}`; + } + const result = toNumber(left) + toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +function subtract(left: string, right: string): string { + const result = toNumber(left) - toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +function multiply(left: string, right: string): string { + const result = toNumber(left) * toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +function divide(left: string, right: string): string { + const result = toNumber(left) / toNumber(right); + return Number.isInteger(result) ? String(result) : String(result); +} + +/** Parses a colour into its channels. */ +export function parseColor(value: string): + | { + r: number; + g: number; + b: number; + } + | undefined { + const hex = value.replace("#", ""); + if (hex.length !== 3 && hex.length !== 6) return undefined; + const expanded = + hex.length === 3 + ? hex + .split("") + .map((char) => char + char) + .join("") + : hex; + return { + r: Number.parseInt(expanded.slice(0, 2), 16), + g: Number.parseInt(expanded.slice(2, 4), 16), + b: Number.parseInt(expanded.slice(4, 6), 16), + }; +} + +function toHex(value: number): string { + return Math.max(0, Math.min(255, Math.round(value))) + .toString(16) + .padStart(2, "0"); +} + +/** Darkens a colour by the given percentage. */ +export function darken(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + const factor = 1 - amount / 100; + return `#${toHex(parsed.r * factor)}${toHex(parsed.g * factor)}${toHex(parsed.b * factor)}`; +} + +/** Lightens a colour by the given percentage. */ +export function lighten(color: string, amount: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + const factor = 1 + amount / 100; + return `#${toHex(parsed.r * factor)}${toHex(parsed.g * factor)}${toHex(parsed.b * factor)}`; +} + +/** Builds a rgba colour. */ +export function rgba(color: string, alpha: number): string { + const parsed = parseColor(color); + if (!parsed) return color; + return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${alpha})`; +} + +/** The functions available inside a stylesheet. */ +export const builtinFunctions: Record<string, (...args: string[]) => string> = { + darken: (...args) => + String(args[0]).startsWith("#") + ? darken(String(args[0]), Number(args[1])) + : `darken(${args.join(", ")})`, + lighten: (...args) => + String(args[0]).startsWith("#") + ? lighten(String(args[0]), Number(args[1])) + : `lighten(${args.join(", ")})`, + rgba: (...args) => + String(args[0]).startsWith("#") + ? rgba(String(args[0]), Number(args[1])) + : `rgba(${args.join(", ")})`, +}; + +/** Evaluates an expression of a stylesheet. */ +export function evaluate( + source: string, + options: EvaluateOptions = {}, +): string { + const trimmed = source.trim(); + if (trimmed.length === 0) return ""; + const parser = new Parser(tokenize(trimmed), { + scope: options.scope ?? {}, + functions: { ...builtinFunctions, ...(options.functions ?? {}) }, + }); + return parser.parse(); +} diff --git a/views/css/index.ts b/views/css/index.ts new file mode 100644 index 0000000..b0dbc50 --- /dev/null +++ b/views/css/index.ts @@ -0,0 +1,43 @@ +import type { Loader, LoaderFile } from "../../contracts/index.ts"; +import { compileCss, type CompileOptions } from "./compiler.ts"; + +/** + * Structured css. + * + * The stylesheets are written like pug: rules are nested, `&` refers to the + * parent selector, `#{$var}` interpolates an expression and `@each` repeats + * a block. The compiler flattens everything into plain css. + */ + +export { compileCss, type CompileOptions } from "./compiler.ts"; +export { evaluate, darken, lighten, rgba } from "./evaluate.ts"; +export { parseCss } from "./parser.ts"; +export { sassLoader, type SassLoaderOptions } from "./sass.ts"; + +export interface StcssLoaderOptions extends CompileOptions { + /** Directory the loader scans, relative to the project root. */ + dir?: string; +} + +/** Loader for structured css files. */ +export function stcssLoader(options: StcssLoaderOptions = {}): Loader { + return { + name: "stcss", + extensions: [".stcss", ".css"], + load(file: LoaderFile) { + return { + data: {}, + content: compileCss(file.source, options), + }; + }, + }; +} + +/** Wraps compiled css into a style tag. */ +export function styleTag(css: string): string { + return `<style>${css}</style>`; +} + +export function viewTransition(name: string): string { + return `view-transition-name: ${name};`; +} diff --git a/views/css/parser.ts b/views/css/parser.ts new file mode 100644 index 0000000..06dfe29 --- /dev/null +++ b/views/css/parser.ts @@ -0,0 +1,381 @@ +export interface CssDeclaration { + property: string; + value: string; +} + +export interface CssRule { + kind: "rule"; + selector: string; + declarations: CssDeclaration[]; + children: CssNode[]; +} + +export interface CssAtRule { + kind: "at-rule"; + name: string; + params: string; + children: CssNode[]; +} + +/** A node of the stylesheet. */ +export type CssNode = CssRule | CssAtRule; + +export interface CssVariable { + name: string; + value: string; +} + +export interface CssMixin { + name: string; + params: { name: string; value?: string }[]; + children: CssNode[]; +} + +export interface CssStylesheet { + variables: CssVariable[]; + mixins: CssMixin[]; + rules: CssNode[]; +} + +const COMMENT = /\/\*[\s\S]*?\*\/|\/\/[^\n]*/g; + +const PUG_PROPERTIES = new Set([ + "accent-color", "align-content", "align-items", "align-self", "animation", "appearance", "aspect-ratio", + "backdrop-filter", "background", "background-color", "background-image", "background-size", "block-size", + "border", "border-radius", "border-color", "border-width", "bottom", "box-shadow", "box-sizing", + "color", "columns", "contain", "content", "cursor", "display", "filter", "flex", "flex-basis", + "flex-direction", "flex-grow", "flex-shrink", "flex-wrap", "float", "font", "font-family", "font-size", + "font-weight", "gap", "grid", "grid-area", "grid-column", "grid-row", "grid-template-columns", + "grid-template-rows", "height", "inline-size", "inset", "justify-content", "justify-items", "justify-self", + "left", "letter-spacing", "line-height", "list-style", "margin", "max-height", "max-width", "min-height", + "min-width", "object-fit", "opacity", "order", "outline", "overflow", "overflow-x", "overflow-y", + "padding", "perspective", "place-content", "place-items", "pointer-events", "position", "right", "rotate", + "scale", "scroll-behavior", "scroll-margin-top", "text-align", "text-decoration", "text-transform", "top", + "transform", "transform-origin", "transition", "translate", "user-select", "visibility", "white-space", "width", + "will-change", "word-break", "z-index", +]); + +function indentation(line: string): number { + let width = 0; + for (const char of line) { + if (char === " ") width += 1; + else if (char === "\t") width += 2; + else break; + } + return width; +} + +function pugDeclaration(line: string): string | undefined { + if (/^\$[\w-]+\s*:\s*.+$/.test(line)) return line; + const variable = /^(\$[\w-]+)\s+(.+)$/.exec(line); + if (variable) return `${variable[1]}: ${variable[2]}`; + if (/^(?:--[\w-]+|[a-zA-Z][\w-]*)\s*:\s+.+$/.test(line)) return line; + const match = /^(--[\w-]+|[a-zA-Z][\w-]*)\s+(.+)$/.exec(line); + if (match && PUG_PROPERTIES.has(match[1] as string)) return `${match[1]}: ${match[2]}`; + return undefined; +} + +function isPugStatement(line: string): boolean { + return /^@(include|measure|predict|concentric)\b/.test(line); +} + +/** Converts indentation-delimited STCSS into the brace form used by the core parser. */ +function normalizePugSyntax(source: string): string { + const withoutInterpolations = source.replace(/#\{[^}]*\}/g, ""); + if (/[{}]/.test(withoutInterpolations)) return source; + const lines = source.split("\n"); + const significant = lines.filter((line) => line.trim().length > 0); + if (!significant.some((line) => /^\s+/.test(line))) return source; + + const output: string[] = []; + const stack: number[] = []; + for (const raw of lines) { + const content = raw.trim(); + if (!content) continue; + const level = indentation(raw); + while (stack.length > 0 && level <= (stack[stack.length - 1] as number)) { + output.push("}"); + stack.pop(); + } + const declaration = pugDeclaration(content); + if (declaration) { + output.push(`${declaration};`); + continue; + } + if (isPugStatement(content)) { + output.push(`${content};`); + continue; + } + const block = content.endsWith(":") ? content.slice(0, -1).trim() : content; + output.push(`${block} {`); + stack.push(level); + } + while (stack.length > 0) { + output.push("}"); + stack.pop(); + } + return output.join("\n"); +} + +class StcssParser { + private index = 0; + + constructor(private readonly source: string) {} + + parse(): CssStylesheet { + const stylesheet: CssStylesheet = { + variables: [], + mixins: [], + rules: [], + }; + const nodes = this.parseNodes(stylesheet.variables); + + for (const node of nodes) { + if (node.kind === "at-rule" && node.name === "mixin") { + stylesheet.mixins.push({ + name: node.params.split(/[\s(]/)[0] ?? "", + params: parseMixinParams(node.params), + children: node.children, + }); + continue; + } + if (node.kind === "at-rule" && node.name === "each") { + // the variable of the loop is handled by the compiler + } + stylesheet.rules.push(node); + } + + return stylesheet; + } + + private parseNodes( + variables?: CssVariable[], + declarations?: CssDeclaration[], + ): CssNode[] { + const nodes: CssNode[] = []; + + while (this.index < this.source.length) { + this.skipWhitespace(); + if (this.index >= this.source.length) break; + if (this.source[this.index] === "}") { + this.index += 1; + break; + } + + const chunk = this.readUntil(["{", ";", "}"]); + if (chunk.trim().length === 0) { + this.index += 1; + continue; + } + + const delimiter = this.source[this.index]; + + if (delimiter === "{") { + this.index += 1; + const selector = chunk.trim(); + if (selector.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(selector); + const name = match?.[1] ?? ""; + const params = (match?.[2] ?? "").trim(); + const body: CssDeclaration[] = []; + const children = this.parseNodes(undefined, body); + if (body.length > 0) { + children.unshift({ + kind: "rule", + selector: "", + declarations: body, + children: [], + }); + } + nodes.push({ kind: "at-rule", name, params, children }); + continue; + } + const rule: CssRule = { + kind: "rule", + selector, + declarations: [], + children: [], + }; + this.parseBody(rule); + nodes.push(rule); + continue; + } + + // declaration or variable + this.index += 1; + const declaration = chunk.trim(); + if (declaration.length === 0) continue; + if (declaration.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(declaration); + nodes.push({ + kind: "at-rule", + name: match?.[1] ?? "", + params: (match?.[2] ?? "").trim(), + children: [], + }); + continue; + } + if (!declaration.startsWith("$")) { + const separator = declaration.indexOf(":"); + if (separator !== -1 && declarations) { + declarations.push({ + property: declaration.slice(0, separator).trim(), + value: declaration.slice(separator + 1).trim(), + }); + } + } + if (declaration.startsWith("$")) { + const separator = declaration.indexOf(":"); + if (separator !== -1 && variables) { + variables.push({ + name: declaration.slice(0, separator).trim(), + value: declaration.slice(separator + 1).trim(), + }); + } + } + } + + return nodes; + } + + private parseBody(rule: CssRule): void { + while (this.index < this.source.length) { + this.skipWhitespace(); + if (this.index >= this.source.length) return; + if (this.source[this.index] === "}") { + this.index += 1; + return; + } + + const chunk = this.readUntil(["{", ";", "}"]); + const delimiter = this.source[this.index]; + + if (delimiter === "{") { + this.index += 1; + const selector = chunk.trim(); + if (selector.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(selector); + const body: CssDeclaration[] = []; + const children = this.parseNodes(undefined, body); + if (body.length > 0) { + children.unshift({ + kind: "rule", + selector: "", + declarations: body, + children: [], + }); + } + rule.children.push({ + kind: "at-rule", + name: match?.[1] ?? "", + params: (match?.[2] ?? "").trim(), + children, + }); + continue; + } + const nested: CssRule = { + kind: "rule", + selector, + declarations: [], + children: [], + }; + this.parseBody(nested); + rule.children.push(nested); + continue; + } + + this.index += 1; + const declaration = chunk.trim(); + if (declaration.length === 0) continue; + if (declaration.startsWith("@")) { + const match = /^@([\w-]+)\s*([\s\S]*)$/.exec(declaration); + rule.children.push({ + kind: "at-rule", + name: match?.[1] ?? "", + params: (match?.[2] ?? "").trim(), + children: [], + }); + continue; + } + const separator = declaration.indexOf(":"); + if (separator === -1) continue; + rule.declarations.push({ + property: declaration.slice(0, separator).trim(), + value: declaration.slice(separator + 1).trim(), + }); + } + } + + private readUntil(delimiters: string[]): string { + const start = this.index; + let quote: string | undefined; + + while (this.index < this.source.length) { + const char = this.source[this.index] as string; + + if (quote) { + if (char === quote) quote = undefined; + this.index += 1; + continue; + } + if (char === '"' || char === "'") { + quote = char; + this.index += 1; + continue; + } + if (char === "#" && this.source[this.index + 1] === "{") { + // an interpolation, the braces belong to the expression + const end = this.source.indexOf("}", this.index); + this.index = end === -1 ? this.source.length : end + 1; + continue; + } + if (delimiters.includes(char)) break; + this.index += 1; + } + + return this.source.slice(start, this.index); + } + + private skipWhitespace(): void { + while (this.index < this.source.length) { + const char = this.source[this.index] as string; + if (/\s/.test(char)) { + this.index += 1; + continue; + } + if (char === "/" && this.source[this.index + 1] === "*") { + const end = this.source.indexOf("*/", this.index); + this.index = end === -1 ? this.source.length : end + 2; + continue; + } + break; + } + } +} + +/** Parses the parameters of a mixin, e.g. `$size: 1rem`. */ +export function parseMixinParams( + params: string, +): { name: string; value?: string }[] { + const open = params.indexOf("("); + const close = params.lastIndexOf(")"); + if (open === -1 || close === -1) return []; + const inner = params.slice(open + 1, close); + return inner + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + const separator = entry.indexOf(":"); + if (separator === -1) return { name: entry }; + return { + name: entry.slice(0, separator).trim(), + value: entry.slice(separator + 1).trim(), + }; + }); +} + +/** Parses a stylesheet. */ +export function parseCss(source: string): CssStylesheet { + return new StcssParser(normalizePugSyntax(source.replace(COMMENT, ""))).parse(); +} diff --git a/views/css/sass.ts b/views/css/sass.ts new file mode 100644 index 0000000..05b20f2 --- /dev/null +++ b/views/css/sass.ts @@ -0,0 +1,29 @@ +import { compileStringAsync } from "sass"; +import { pathToFileURL } from "node:url"; +import type { Loader, LoaderFile } from "../../contracts/index.ts"; +import { compileCss, type CompileOptions } from "./compiler.ts"; + +export interface SassLoaderOptions extends CompileOptions { + /** Directory scanned by the Sass integration, relative to the project root. */ + dir?: string; + /** Extra directories searched by Sass `@use` and `@import`. */ + loadPaths?: string[]; +} + +/** + * Compiles Sass to CSS and then runs it through STCSS. This lets a project + * move Sass files over incrementally while keeping one CSS output path. + */ +export function sassLoader(options: SassLoaderOptions = {}): Loader { + return { + name: "sass", + extensions: [".scss", ".sass"], + async load(file: LoaderFile) { + const result = await compileStringAsync(file.source, { + url: pathToFileURL(file.path), + loadPaths: options.loadPaths, + }); + return { data: {}, content: compileCss(result.css, options) }; + }, + }; +} diff --git a/views/engine/compiler.ts b/views/engine/compiler.ts new file mode 100644 index 0000000..6123adc --- /dev/null +++ b/views/engine/compiler.ts @@ -0,0 +1,707 @@ +import type { Node, PropDeclaration, RawAttribute } from "./types.ts"; + +export interface CompileOptions { + /** Absolute path of the template, used to resolve frontmatter imports. */ + path: string; + /** Directory of the template, used to resolve frontmatter imports. */ + dir: string; + /** Frontmatter of the template. */ + frontmatter?: string; +} + +export interface CompiledSource { + /** Source of the module creating the compiled template. */ + source: string; + /** Prop declarations found in the template. */ + props: PropDeclaration[]; + /** Names declared by the frontmatter. */ + frontmatterNames: string[]; +} + +const RESERVED = new Set([ + "await", + "async", + "break", + "case", + "catch", + "class", + "const", + "continue", + "debugger", + "default", + "delete", + "do", + "else", + "enum", + "export", + "extends", + "false", + "finally", + "for", + "from", + "function", + "get", + "if", + "implements", + "import", + "in", + "instanceof", + "interface", + "let", + "new", + "null", + "of", + "package", + "private", + "protected", + "public", + "return", + "set", + "static", + "super", + "switch", + "this", + "throw", + "true", + "try", + "typeof", + "undefined", + "var", + "void", + "while", + "with", + "yield", +]); + +/** + * Identifiers used by an expression. + */ +export function collectIdentifiers(code: string): string[] { + const withoutStrings = code + .replace(/"(?:[^"\\]|\\.)*"/g, '""') + .replace(/'(?:[^'\\]|\\.)*'/g, "''") + .replace(/`(?:[^`\\]|\\.)*`/g, "``"); + const names = new Set<string>(); + const pattern = /(?<![.\w$])([A-Za-z_$][\w$]*)/g; + let match = pattern.exec(withoutStrings); + while (match) { + const name = match[1] as string; + if (!RESERVED.has(name)) names.add(name); + match = pattern.exec(withoutStrings); + } + return [...names]; +} + +/** + * Names declared by the frontmatter. + */ +export function extractDeclarations(code: string): string[] { + const names: string[] = []; + const pattern = /^\s*(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/gm; + let match = pattern.exec(code); + while (match) { + names.push(match[1] as string); + match = pattern.exec(code); + } + return names; +} + +const VOID_ELEMENTS = new Set([ + "area", + "base", + "br", + "col", + "embed", + "hr", + "img", + "input", + "link", + "meta", + "param", + "source", + "track", + "wbr", +]); + +/** Escapes a string for use inside a JavaScript string literal. */ +function jsString(value: string): string { + return JSON.stringify(value); +} + +/** Escapes HTML entities of static text. */ +export function escapeHtmlText(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +/** Resolves an import specifier of the frontmatter. */ +function resolveSpecifier(specifier: string, dir: string): string { + if (!specifier.startsWith(".")) return specifier; + return `${dir}/${specifier}`.replace(/\/{2,}/g, "/"); +} + +/** + * Rewrites the `import` statements of the frontmatter into dynamic imports, + * because the frontmatter is compiled into a function body. + */ +export function transformFrontmatter(code: string, dir: string): string { + const lines = code.split("\n"); + const output: string[] = []; + + for (const line of lines) { + const named = /^\s*import\s+([\s\S]+?)\s+from\s+['"]([^'"]+)['"]\s*$/.exec( + line, + ); + if (named) { + const clause = (named[1] as string).trim(); + const specifier = resolveSpecifier(named[2] as string, dir); + const defaultMatch = /^([A-Za-z_$][\w$]*)\s*,\s*([\s\S]+)$/.exec(clause); + if (defaultMatch) { + const alias = defaultMatch[1] as string; + const rest = (defaultMatch[2] as string).replace(/^\{|\}$/g, "").trim(); + output.push(`const __module = await import(${jsString(specifier)});`); + output.push(`const ${alias} = __module.default;`); + output.push(`const { ${rest} } = __module;`); + continue; + } + if (/^\*\s*as\s+/.test(clause)) { + const alias = clause.replace(/^\*\s*as\s+/, "").trim(); + output.push(`const ${alias} = await import(${jsString(specifier)});`); + continue; + } + if (/^\{[\s\S]*\}$/.test(clause)) { + const names = clause.replace(/^\{|\}$/g, "").trim(); + output.push( + `const { ${names} } = await import(${jsString(specifier)});`, + ); + continue; + } + if (/^[A-Za-z_$][\w$]*$/.test(clause)) { + output.push( + `const ${clause} = (await import(${jsString(specifier)})).default;`, + ); + continue; + } + output.push(`await import(${jsString(specifier)});`); + continue; + } + + const sideEffect = /^\s*import\s+['"]([^'"]+)['"]\s*$/.exec(line); + if (sideEffect) { + output.push( + `await import(${jsString(resolveSpecifier(sideEffect[1] as string, dir))});`, + ); + continue; + } + + output.push(line); + } + + return output.join("\n"); +} + +/** Collects every mixin definition of a template. */ +export function collectMixinNodes( + nodes: Node[], +): Extract<Node, { kind: "mixin" }>[] { + const result: Extract<Node, { kind: "mixin" }>[] = []; + for (const node of nodes) { + if (node.kind === "mixin") { + result.push(node); + continue; + } + const children = childrenOf(node); + if (children) result.push(...collectMixinNodes(children)); + } + return result; +} + +/** Collects every named block of a template. */ +export function collectBlockNodes(nodes: Node[]): { + name: string; + mode: "replace" | "append" | "prepend"; + children: Node[]; +}[] { + const result: { + name: string; + mode: "replace" | "append" | "prepend"; + children: Node[]; + }[] = []; + for (const node of nodes) { + if (node.kind === "block") { + result.push({ + name: node.name, + mode: node.mode, + children: node.children, + }); + continue; + } + const children = childrenOf(node); + if (children) result.push(...collectBlockNodes(children)); + } + return result; +} + +class Compiler { + private readonly buffer: string[] = []; + + compile(nodes: Node[]): string { + this.emitBody(nodes); + return this.buffer.join("\n"); + } + + private emit(line: string): void { + this.buffer.push(line); + } + + private emitBody(nodes: Node[]): void { + for (const node of nodes) { + this.emitNode(node); + } + } + + private emitNode(node: Node): void { + switch (node.kind) { + case "element": + this.emitElement(node); + break; + case "text": + this.emitText(node.parts); + break; + case "rawtext": + this.emit(`__push(${jsString(escapeHtmlText(node.value))});`); + break; + case "code": + this.emit(node.code); + break; + case "out": + this.emit( + `__push(__helpers.render(await (${node.code}), ${node.escaped !== false}));`, + ); + break; + case "comment": + if (!node.silent) { + this.emit(`__push(${jsString(`<!--${node.value}-->`)});`); + } + break; + case "doctype": + this.emit(`__push(${jsString(`<!DOCTYPE ${node.value || "html"}>`)});`); + break; + case "if": + this.emitIf(node.branches); + break; + case "each": + this.emitEach(node); + break; + case "while": + this.emit(`while (${node.test}) {`); + this.emitBody(node.children); + this.emit("}"); + break; + case "case": + this.emitCase(node); + break; + case "mixin": + break; + case "mixin-call": + this.emit( + `__push(await __mixins[${jsString(node.name)}](__runtime, __ctx, ${node.args || ""}));`, + ); + break; + case "include": + this.emitBody(node.children); + break; + case "extends": + break; + case "block": + case "slot": + this.emitBlock(node.name, node.children); + break; + case "yield": + this.emitBlock(node.name, []); + break; + case "portal": + this.emit(`__push(${jsString(`<div data-muddle-portal="${node.name}" style="view-transition-name:${node.name}">`)});`); + this.emitBody(node.children); + this.emit(`__push("</div>");`); + break; + case "props": + case "test": + break; + case "component": + this.emitComponent(node.name, node.props, node.renders, false); + break; + case "island": + this.emitComponent(node.name, node.props, node.renders, true); + break; + } + } + + private emitElement(node: Extract<Node, { kind: "element" }>): void { + const anchor = node.anchor + ? node.anchor.expression + ? ` + __helpers.attr("data-muddle-anchor", ${jsString(node.anchor.name)} + String(${node.anchor.expression}))` + : ` + __helpers.attr("data-muddle-anchor", ${jsString(node.anchor.name)})` + : ""; + const attributes = this.emitAttributes(node.attributes) + anchor; + this.emit( + `__push(${jsString(`<${node.name}`)}${attributes} + ${jsString(">")});`, + ); + + if (VOID_ELEMENTS.has(node.name)) return; + + this.emitBody(node.children); + this.emit(`__push(${jsString(`</${node.name}>`)});`); + } + + /** + * Renders the attributes of a tag. + */ + private emitAttributes(attributes: RawAttribute[]): string { + const classes: RawAttribute[] = []; + const others: RawAttribute[] = []; + for (const attribute of attributes) { + if (attribute.name === "class") classes.push(attribute); + else others.push(attribute); + } + + let rendered = others + .map((attribute) => this.emitAttribute(attribute)) + .join(""); + + if (classes.length === 1) { + rendered += this.emitAttribute(classes[0] as RawAttribute); + return rendered; + } + if (classes.length > 1) { + const values = classes + .map((attribute) => + attribute.kind === "string" + ? jsString(attribute.value ?? "") + : `(${attribute.value ?? ""})`, + ) + .join(", "); + rendered += ` + __helpers.attr("class", __helpers.classes([${values}]))`; + } + return rendered; + } + + private emitAttribute(attribute: RawAttribute): string { + if (attribute.kind === "boolean") { + return ` + __helpers.attrBoolean(${jsString(attribute.name)})`; + } + if (attribute.kind === "string") { + return ` + __helpers.attr(${jsString(attribute.name)}, ${jsString(attribute.value ?? "")})`; + } + if (attribute.name === "style") { + return ` + __helpers.attr("style", __helpers.style(${attribute.value ?? ""}))`; + } + return ` + __helpers.attr(${jsString(attribute.name)}, (${attribute.value ?? ""}))`; + } + + private emitText( + parts: { + type: "literal" | "expression"; + value?: string; + code?: string; + escaped?: boolean; + }[], + ): void { + for (const part of parts) { + if (part.type === "literal") { + this.emit(`__push(${jsString(escapeHtmlText(part.value ?? ""))});`); + continue; + } + const code = part.code ?? ""; + this.emit( + `__push(__helpers.render(await (${code}), ${part.escaped !== false}));`, + ); + } + } + + private emitIf( + branches: { test: string | null; negate: boolean; children: Node[] }[], + ): void { + branches.forEach((branch, index) => { + if (index > 0) { + this.emit("}"); + } + if (branch.test === null) { + this.emit("else {"); + } else { + const test = branch.negate ? `!(${branch.test})` : branch.test; + this.emit(`${index === 0 ? "if" : "else if"} (${test}) {`); + } + this.emitBody(branch.children); + }); + this.emit("}"); + } + + private emitEach(node: Extract<Node, { kind: "each" }>): void { + this.emit("{"); + this.emit(`const __list = (${node.list});`); + this.emit("if (Array.isArray(__list)) {"); + this.emit("for (let __index = 0; __index < __list.length; __index++) {"); + this.emit(`const ${node.value} = __list[__index];`); + if (node.key) { + this.emit(`const ${node.key} = __index;`); + } + this.emitBody(node.children); + this.emit("}"); + this.emit("} else {"); + this.emit("for (const __key of Object.keys(__list ?? {})) {"); + this.emit(`const ${node.value} = __list[__key];`); + if (node.key) { + this.emit(`const ${node.key} = __key;`); + } + this.emitBody(node.children); + this.emit("}"); + this.emit("}"); + this.emit("}"); + } + + private emitCase(node: Extract<Node, { kind: "case" }>): void { + this.emit(`switch (${node.subject}) {`); + for (const entry of node.cases) { + if (entry.test === undefined) { + this.emit("default: {"); + } else { + this.emit(`case (${entry.test}): {`); + } + this.emitBody(entry.children); + this.emit("break;"); + this.emit("}"); + } + this.emit("}"); + } + + private emitBlock(name: string, children: Node[]): void { + this.emit( + `__push(await __runtime.getBlock(${jsString(name)}, async (__runtime, __ctx) => {`, + ); + this.emit("const __out = [];"); + this.emit("const __push = (__value) => { __out.push(__value); };"); + this.emit("with (__ctx) {"); + this.emitBody(children); + this.emit("}"); + this.emit('return __out.join("");'); + this.emit("}));"); + } + + private emitComponent( + name: string, + props: Record<string, string>, + renders: { name: string; children: Node[] }[] = [], + island = false, + ): void { + const entries = Object.entries(props).map( + ([key, value]) => `${jsString(key)}: (${value || "undefined"})`, + ); + + for (const render of renders) { + const renderer = new Compiler(); + const body = renderer.compile(render.children); + entries.push( + `${jsString(render.name)}: async () => { const __out = []; const __push = (__value) => { __out.push(__value); }; const __helpers = __runtime.helpers; with (__ctx) { ${body} } return __out.join(""); }`, + ); + } + + const method = island ? "renderIsland" : "renderComponent"; + this.emit( + `__push(await __runtime.${method}(${jsString(name)}, { ${entries.join(", ")} }));`, + ); + } +} + + +export function compileTemplate( + nodes: Node[], + options: CompileOptions, +): CompiledSource { + const bodyCompiler = new Compiler(); + const body = bodyCompiler.compile(nodes); + const uses = collectUses(nodes); + const tests = collectTests(nodes); + + const blockSource: string[] = []; + for (const block of collectBlockNodes(nodes)) { + const blockCompiler = new Compiler(); + const blockBody = blockCompiler.compile(block.children); + blockSource.push( + `${jsString(block.name)}: { mode: ${jsString(block.mode)}, fn: async (__runtime, __ctx) => { const __out = []; const __push = (__value) => { __out.push(__value); }; const __helpers = __runtime.helpers; with (__ctx) { ${blockBody} } return __out.join(""); } }`, + ); + } + + const mixinSource: string[] = []; + for (const mixin of collectMixinNodes(nodes)) { + const mixinCompiler = new Compiler(); + const mixinBody = mixinCompiler.compile(mixin.children); + const params = mixin.params.length > 0 ? mixin.params : ""; + mixinSource.push( + `${jsString(mixin.name)}: async (__runtime, __ctx, ${params}) => { const __out = []; const __push = (__value) => { __out.push(__value); }; const __helpers = __runtime.helpers; with (__ctx) { ${mixinBody} } return __out.join(""); }`, + ); + } + + const frontmatter = + options.frontmatter && options.frontmatter.trim().length > 0 + ? transformFrontmatter(options.frontmatter, options.dir) + : ""; + const frontmatterNames = + frontmatter.length > 0 ? extractDeclarations(frontmatter) : []; + const frontmatterAssignments = frontmatterNames + .map((name) => ` __values.${name} = ${name};`) + .join("\n"); + + const source = ` +const __blocks = { ${blockSource.join(", ")} }; +const __mixins = { ${mixinSource.join(", ")} }; +const __frontmatter = async (__runtime, __ctx) => { + const __values = {}; + const __helpers = __runtime.helpers; + const __muddle = __runtime.file; + with (__ctx) { +${frontmatter} +${frontmatterAssignments} + } + return __values; +}; +const __template = async (__runtime, __ctx) => { + const __out = []; + const __push = (__value) => { __out.push(__value); }; + const __helpers = __runtime.helpers; + const __muddle = __runtime.file; + with (__ctx) { + ${body} + } + return __out.join(""); +}; +return { template: __template, frontmatter: __frontmatter, blocks: __blocks, mixins: __mixins, uses: ${JSON.stringify(uses)}, tests: ${JSON.stringify(tests)} }; +`; + + return { source, props: [], frontmatterNames }; +} + +/** + * The assertions of a template. + */ +export function collectTests(nodes: Node[]): string[] { + const tests: string[] = []; + const visit = (nodes: Node[]): void => { + for (const node of nodes) { + if (node.kind === "test") { + tests.push(node.code); + continue; + } + const children = childrenOf(node); + if (children) visit(children); + } + }; + visit(nodes); + return tests; +} + +/** + * Every identifier used by a template. + */ +export function collectUses(nodes: Node[]): string[] { + const names = new Set<string>(); + const declared = new Set<string>(); + + const visit = (node: Node): void => { + if (node.kind === "mixin") { + for (const name of mixinParamNames(node.params)) declared.add(name); + } + switch (node.kind) { + case "out": + for (const name of collectIdentifiers(node.code)) names.add(name); + break; + case "code": + for (const name of collectIdentifiers(node.code)) names.add(name); + break; + case "element": + for (const attribute of node.attributes) { + if (attribute.kind !== "expression") continue; + for (const name of collectIdentifiers(attribute.value ?? "")) { + names.add(name); + } + } + break; + case "text": + for (const part of node.parts) { + if (part.type !== "expression") continue; + for (const name of collectIdentifiers(part.code ?? "")) { + names.add(name); + } + } + break; + case "if": + for (const branch of node.branches) { + if (branch.test) { + for (const name of collectIdentifiers(branch.test)) names.add(name); + } + } + break; + case "each": + for (const name of collectIdentifiers(node.list)) names.add(name); + break; + case "while": + for (const name of collectIdentifiers(node.test)) names.add(name); + break; + case "case": + for (const name of collectIdentifiers(node.subject)) names.add(name); + break; + case "mixin-call": + for (const name of collectIdentifiers(node.args)) names.add(name); + break; + case "component": + case "island": + for (const value of Object.values(node.props)) { + for (const name of collectIdentifiers(value)) names.add(name); + } + break; + } + + const children = childrenOf(node); + if (children) { + for (const child of children) visit(child); + } + }; + + for (const node of nodes) visit(node); + return [...names].filter((name) => !declared.has(name)); +} + +/** Names of the parameters of a mixin. */ +export function mixinParamNames(params: string): string[] { + const names: string[] = []; + const braced = /[{[(]([\s\S]*)[}\])]/.exec(params); + const inner = braced ? (braced[1] as string) : params; + for (const part of inner.split(",")) { + const cleaned = part.trim().replace(/^[.]{3}/, ""); + if (!cleaned) continue; + const name = /^[A-Za-z_$][\w$]*/.exec(cleaned); + if (name) names.push(name[0]); + } + return names; +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} diff --git a/views/engine/lexer.ts b/views/engine/lexer.ts new file mode 100644 index 0000000..2f80388 --- /dev/null +++ b/views/engine/lexer.ts @@ -0,0 +1,616 @@ +import type { RawAttribute, TextPart, Token } from "./types.ts"; + +const KEYWORDS = new Set([ + "if", + "unless", + "else", + "each", + "while", + "case", + "when", + "default", + "mixin", + "include", + "extends", + "block", + "append", + "prepend", + "slot", + "yield", + "props", + "component", + "island", + "render", + "portal", +]); + +const TAG_NAME = /^[a-zA-Z][a-zA-Z0-9:_-]*/; + +/** Converts tabs to spaces so indentation can be compared by width. */ +function indentWidth(line: string): { indent: number; rest: string } { + let indent = 0; + let index = 0; + while (index < line.length) { + const char = line[index]; + if (char === " ") { + indent += 1; + } else if (char === "\t") { + indent += 2; + } else { + break; + } + index += 1; + } + return { indent, rest: line.slice(index) }; +} + +/** Counts the parentheses that are still open at the end of a line. */ +function parenBalance(line: string): number { + let balance = 0; + let quote: string | null = null; + for (let index = 0; index < line.length; index++) { + const char = line[index]; + if (quote) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "(") balance += 1; + if (char === ")") balance -= 1; + } + return balance; +} + +interface LogicalLine { + indent: number; + text: string; + line: number; +} + +/** Folds the source into logical lines, joining open attribute lists. */ +export function foldLines(source: string): LogicalLine[] { + const lines = source.split("\n"); + const result: LogicalLine[] = []; + let index = 0; + + while (index < lines.length) { + const raw = lines[index] as string; + const { indent, rest } = indentWidth(raw); + const lineNumber = index + 1; + + if (rest.trim().length === 0) { + index += 1; + continue; + } + + let text = rest; + while (parenBalance(text) > 0 && index + 1 < lines.length) { + index += 1; + text += `\n${(lines[index] as string).trim()}`; + } + + result.push({ indent, text, line: lineNumber }); + index += 1; + } + + return result; +} + +/** Extracts the Astro style frontmatter of a template. */ +export function extractFrontmatter(source: string): { + frontmatter: string; + body: string; +} { + const lines = source.split("\n"); + let start = 0; + while (start < lines.length && (lines[start] as string).trim().length === 0) + start += 1; + let frontmatter = ""; + if (start >= lines.length || (lines[start] as string).trim() !== "---") { + return extractScriptBlocks(source, frontmatter); + } + + const body: string[] = []; + let index = start + 1; + const frontmatterLines: string[] = []; + for (; index < lines.length; index++) { + const line = lines[index] as string; + if (line.trim() === "---") { + index += 1; + break; + } + frontmatterLines.push(line); + } + body.push(...lines.slice(index)); + frontmatter = frontmatterLines.join("\n"); + return extractScriptBlocks(body.join("\n"), frontmatter); +} + +/** Reads `javascript!` and `typescript!` blocks terminated by `end`. */ +export function extractScriptBlocks(source: string, frontmatter = ""): { frontmatter: string; body: string } { + const body: string[] = []; + const scripts: string[] = []; + const lines = source.split("\n"); + let index = 0; + while (index < lines.length) { + const directive = /^\s*(?:javascript|typescript)!\s*$/.test(lines[index] as string); + if (!directive) { body.push(lines[index] as string); index += 1; continue; } + index += 1; + while (index < lines.length && !/^\s*end\s*$/.test(lines[index] as string)) { scripts.push(lines[index] as string); index += 1; } + if (index < lines.length) index += 1; + } + return { frontmatter: [frontmatter, ...scripts].filter(Boolean).join("\n"), body: body.join("\n") }; +} + +/** Finds the index of the `}` matching the `{` at `start`. */ +export function findClosingBrace(text: string, start: number): number { + let depth = 0; + let quote: string | null = null; + for (let index = start; index < text.length; index++) { + const char = text[index]; + if (quote) { + if (char === "\\") { + index += 1; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "{") depth += 1; + if (char === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +/** Splits text into literal and interpolated parts. */ +export function parseTextParts(text: string): TextPart[] { + const parts: TextPart[] = []; + let literal = ""; + let index = 0; + + const flush = () => { + if (literal.length > 0) { + parts.push({ type: "literal", value: literal }); + literal = ""; + } + }; + + while (index < text.length) { + const char = text[index]; + const next = text[index + 1]; + + if ((char === "#" || char === "!") && next === "{") { + const end = findClosingBrace(text, index + 1); + if (end !== -1) { + flush(); + parts.push({ + type: "expression", + code: text.slice(index + 2, end).trim(), + escaped: char === "#", + }); + index = end + 1; + continue; + } + } + + literal += char; + index += 1; + } + + flush(); + return parts; +} + +/** + * `true` when the text after the whitespace starts a new attribute. + */ +export function startsNewAttribute(source: string, index: number): boolean { + const rest = source.slice(index); + return /^\s+[A-Za-z_][\w-]*\s*(?:=|,|\)|$)/.test(rest); +} + +/** Parses the attribute list of a tag. */ +export function parseAttributes(source: string): RawAttribute[] { + const attributes: RawAttribute[] = []; + let index = 0; + + while (index < source.length) { + while ( + index < source.length && + (/\s/.test(source[index] as string) || source[index] === ",") + ) { + index += 1; + } + if (index >= source.length) break; + + const nameMatch = /^[^\s=,()]+/.exec(source.slice(index)); + if (!nameMatch) { + index += 1; + continue; + } + const name = nameMatch[0]; + index += name.length; + + while (index < source.length && /\s/.test(source[index] as string)) + index += 1; + + if (source[index] !== "=") { + attributes.push({ name, kind: "boolean" }); + continue; + } + + index += 1; + while (index < source.length && /\s/.test(source[index] as string)) + index += 1; + + const quote = source[index]; + if (quote === '"' || quote === "'") { + const end = source.indexOf(quote, index + 1); + const value = + end === -1 ? source.slice(index + 1) : source.slice(index + 1, end); + attributes.push({ name, kind: "string", value }); + index = end === -1 ? source.length : end + 1; + continue; + } + + if (quote === "(") { + let depth = 0; + const start = index; + for (; index < source.length; index++) { + const char = source[index]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) { + index += 1; + break; + } + } + } + attributes.push({ + name, + kind: "expression", + value: source.slice(start + 1, index - 1).trim(), + }); + continue; + } + + const start = index; + let depth = 0; + while (index < source.length) { + const char = source[index] as string; + if (char === "(" || char === "[" || char === "{") depth += 1; + if (char === ")" || char === "]" || char === "}") depth -= 1; + if (depth === 0 && (char === "," || char === ")")) break; + if (depth === 0 && /\s/.test(char) && startsNewAttribute(source, index)) { + break; + } + index += 1; + } + attributes.push({ + name, + kind: "expression", + value: source.slice(start, index).trim(), + }); + } + + return attributes; +} + +/** Parses a tag line into its name, attributes and inline text. */ +export function parseTagLine(text: string): { + name: string; + attributes: RawAttribute[]; + text?: string; + output?: string; + escaped?: boolean; + blockText: boolean; + anchor?: { name: string; expression?: string }; +} { + let rest = text; + const nameMatch = TAG_NAME.exec(rest); + const name = nameMatch ? nameMatch[0] : "div"; + if (nameMatch) rest = rest.slice(name.length); + + const classes: string[] = []; + let id: string | undefined; + + while (rest.length > 0 && (rest[0] === "." || rest[0] === "#")) { + const marker = rest[0] as string; + const valueMatch = /^[^\s.#(=/!@]+/.exec(rest.slice(1)); + if (!valueMatch) break; + if (marker === ".") classes.push(valueMatch[0]); + else id = valueMatch[0]; + rest = rest.slice(1 + valueMatch[0].length); + } + + let anchor: { name: string; expression?: string } | undefined; + if (rest.startsWith("@")) { + const match = /^@([A-Za-z_][\w-]*(?:\.[A-Za-z_][\w-]*)*)/.exec(rest); + if (match) { + let length = match[0].length; + let expression: string | undefined; + // `@docs.{section.id}` keeps the stable group prefix while deriving its + // leaf identity from the current composition scope. + if (rest.slice(length).startsWith(".{")) { + const end = rest.indexOf("}", length + 2); + if (end !== -1) { + expression = rest.slice(length + 2, end).trim() || undefined; + length = end + 1; + anchor = { name: `${match[1]}.`, ...(expression ? { expression } : {}) }; + } + } + if (rest[length] === "(") { + let depth = 0; + let end = length; + for (; end < rest.length; end += 1) { + if (rest[end] === "(") depth += 1; + if (rest[end] === ")") { + depth -= 1; + if (depth === 0) break; + } + } + if (depth === 0) { + expression = rest.slice(length + 1, end).trim() || undefined; + length = end + 1; + } + } + anchor ??= { name: match[1] as string, ...(expression ? { expression } : {}) }; + rest = rest.slice(length); + } + } + + const attributes: RawAttribute[] = []; + if (rest.startsWith("(")) { + let depth = 0; + let end = 0; + for (; end < rest.length; end++) { + const char = rest[end]; + if (char === "(") depth += 1; + if (char === ")") { + depth -= 1; + if (depth === 0) { + end += 1; + break; + } + } + } + attributes.push(...parseAttributes(rest.slice(1, end - 1))); + rest = rest.slice(end); + } + + if (id !== undefined) { + attributes.unshift({ name: "id", kind: "string", value: id }); + } + if (classes.length > 0) { + attributes.unshift({ + name: "class", + kind: "string", + value: classes.join(" "), + }); + } + + const inline = rest.trim(); + if (inline === ".") { + return { name, attributes, blockText: true, anchor }; + } + + if (inline.startsWith("!=")) { + return { + name, + attributes, + output: inline.slice(2).trim(), + escaped: false, + blockText: false, + anchor, + }; + } + + if (inline.startsWith("=")) { + return { + name, + attributes, + output: inline.slice(1).trim(), + escaped: true, + blockText: false, + anchor, + }; + } + + return { + name, + attributes, + text: inline.length > 0 ? inline : undefined, + blockText: false, + anchor, + }; +} + +/** Splits `each value, key in list` into its parts. */ +export function parseEach(value: string): { + value: string; + key?: string; + list: string; +} { + const match = /^([\w$]+)\s*(?:,\s*([\w$]+)\s*)?\s+(?:in|of)\s+(.+)$/.exec( + value.trim(), + ); + if (!match) { + throw new Error(`Invalid each expression: ${value}`); + } + return { + value: match[1] as string, + key: match[2], + list: (match[3] as string).trim(), + }; +} + +/** Turns a template into a flat list of tokens. */ +export function lex(source: string): Token[] { + const { frontmatter, body } = extractFrontmatter(source); + const tokens: Token[] = []; + + if (frontmatter.trim().length > 0) { + tokens.push({ + type: "frontmatter", + indent: 0, + line: 1, + value: frontmatter, + }); + } + + for (const line of foldLines(body)) { + const { text, indent, line: lineNumber } = line; + const trimmed = text.trim(); + + if (trimmed.startsWith("//-")) { + tokens.push({ + type: "comment", + indent, + line: lineNumber, + value: trimmed.slice(3).trim(), + silent: true, + }); + continue; + } + + if (trimmed.startsWith("//")) { + tokens.push({ + type: "comment", + indent, + line: lineNumber, + value: trimmed.slice(2).trim(), + silent: false, + }); + continue; + } + + if (trimmed.startsWith("!=")) { + tokens.push({ + type: "out", + indent, + line: lineNumber, + value: trimmed.slice(2).trim(), + escaped: false, + }); + continue; + } + + if (trimmed.startsWith("=")) { + tokens.push({ + type: "out", + indent, + line: lineNumber, + value: trimmed.slice(1).trim(), + escaped: true, + }); + continue; + } + + if (trimmed.startsWith("- ") || trimmed === "-") { + tokens.push({ + type: "code", + indent, + line: lineNumber, + value: trimmed === "-" ? "" : trimmed.slice(2).trim(), + }); + continue; + } + + if (trimmed.startsWith("|")) { + tokens.push({ + type: "text", + indent, + line: lineNumber, + value: trimmed.slice(1).trim(), + }); + continue; + } + + if (trimmed === "$$$") { + tokens.push({ + type: "test", + indent, + line: lineNumber, + value: trimmed, + }); + continue; + } + + if (/^doctype\b/i.test(trimmed)) { + tokens.push({ + type: "doctype", + indent, + line: lineNumber, + value: trimmed.slice("doctype".length).trim(), + }); + continue; + } + + const keywordMatch = /^([a-zA-Z]+)\b\s*(.*)$/.exec(trimmed); + if (keywordMatch) { + const keyword = keywordMatch[1] as string; + const keywordValue = (keywordMatch[2] ?? "").trim(); + if (KEYWORDS.has(keyword)) { + tokens.push({ + type: "keyword", + indent, + line: lineNumber, + value: trimmed, + keyword, + keywordValue, + }); + continue; + } + } + + if (trimmed.startsWith("+")) { + const callMatch = /^\+([a-zA-Z_$][\w$]*)\s*(?:\(([\s\S]*)\))?\s*$/.exec( + trimmed, + ); + if (callMatch) { + tokens.push({ + type: "keyword", + indent, + line: lineNumber, + value: trimmed, + keyword: "mixin-call", + keywordValue: callMatch[1] as string, + text: (callMatch[2] ?? "").trim(), + }); + continue; + } + } + + const tag = parseTagLine(text); + tokens.push({ + type: "tag", + indent, + line: lineNumber, + value: trimmed, + name: tag.name, + attributes: tag.attributes, + text: tag.text, + output: tag.output, + escaped: tag.escaped, + blockText: tag.blockText, + anchor: tag.anchor, + }); + } + + return tokens; +} diff --git a/views/engine/parser.ts b/views/engine/parser.ts new file mode 100644 index 0000000..be3d234 --- /dev/null +++ b/views/engine/parser.ts @@ -0,0 +1,485 @@ +import { parseEach, parseTextParts } from "./lexer.ts"; +import type { + CaseNode, + IfBranch, + Node, + PropDeclaration, + Token, +} from "./types.ts"; + +interface TokenNode { + token: Token; + children: TokenNode[]; +} + +/** Builds the indentation tree of the token list. */ +export function buildTree(tokens: Token[]): TokenNode[] { + const root: TokenNode[] = []; + const stack: { indent: number; nodes: TokenNode[] }[] = [ + { indent: -1, nodes: root }, + ]; + + for (const token of tokens) { + while ( + stack.length > 1 && + token.indent <= (stack[stack.length - 1] as { indent: number }).indent + ) { + stack.pop(); + } + const current = stack[stack.length - 1] as { + indent: number; + nodes: TokenNode[]; + }; + const node: TokenNode = { token, children: [] }; + current.nodes.push(node); + stack.push({ indent: token.indent, nodes: node.children }); + } + + return root; +} + +/** + * Splits a type into the type and the default value. + */ +export function splitDefault(value: string): { + type: string; + default?: string; +} { + for (let index = 0; index < value.length; index += 1) { + if (value[index] !== "=") continue; + const next = value[index + 1]; + const previous = value[index - 1]; + if (next === "=" || next === ">") { + index += 1; + continue; + } + if (previous === "=" || previous === "!" || previous === "<" || previous === ">") { + continue; + } + return { + type: value.slice(0, index).trim(), + default: value.slice(index + 1).trim(), + }; + } + return { type: value.trim() }; +} + +function parseProps(children: TokenNode[]): PropDeclaration[] { + const props: PropDeclaration[] = []; + for (const child of children) { + const token = child.token; + if (token.type !== "tag" && token.type !== "text") continue; + const rawName = (token.name ?? token.value).replace(/:$/, "").trim(); + if (!rawName) continue; + let optional = rawName.endsWith("?"); + const name = optional ? rawName.slice(0, -1) : rawName; + let rest = (token.text ?? "").trim(); + if (rest.startsWith("?")) { + optional = true; + rest = rest.slice(1).trim(); + } + if (rest.startsWith(":")) { + rest = rest.slice(1).trim(); + } + + const split = splitDefault(rest); + const type = split.type; + const defaultValue = split.default; + + props.push({ + name, + type: type.length > 0 ? type : "unknown", + optional, + defaultValue, + }); + } + return props; +} + +function parseComponentProps(children: TokenNode[]): Record<string, string> { + const props: Record<string, string> = {}; + for (const child of children) { + const token = child.token; + if (token.type !== "tag" && token.type !== "text") continue; + const name = (token.name ?? token.value).replace(/:$/, "").trim(); + if (!name) continue; + props[name] = (token.text ?? "").trim(); + } + return props; +} + +function transform(tokenNodes: TokenNode[]): Node[] { + const nodes: Node[] = []; + let index = 0; + + while (index < tokenNodes.length) { + const entry = tokenNodes[index] as TokenNode; + const token = entry.token; + + switch (token.type) { + case "tag": { + let children = transform(entry.children); + if (token.output !== undefined) { + children = [ + { + kind: "out", + code: token.output, + escaped: token.escaped !== false, + }, + ...children, + ]; + } + if (token.text !== undefined && token.text.length > 0) { + children = [ + { kind: "text", parts: parseTextParts(token.text) }, + ...children, + ]; + } + if (token.blockText) { + const raw = entry.children + .map((child) => child.token.value.trim()) + .join("\n"); + children = [{ kind: "text", parts: parseTextParts(raw) }]; + } + nodes.push({ + kind: "element", + name: token.name ?? "div", + attributes: token.attributes ?? [], + children, + selfClosing: false, + anchor: token.anchor, + }); + index += 1; + break; + } + + case "text": { + nodes.push({ kind: "text", parts: parseTextParts(token.value) }); + index += 1; + break; + } + + case "code": { + nodes.push({ kind: "code", code: token.value }); + index += 1; + break; + } + + case "out": { + nodes.push({ + kind: "out", + code: token.value, + escaped: token.escaped !== false, + }); + index += 1; + break; + } + + case "comment": { + nodes.push({ + kind: "comment", + value: token.value ?? "", + silent: token.silent === true, + children: transform(entry.children), + }); + index += 1; + break; + } + + case "test": { + nodes.push({ + kind: "test", + code: entry.children + .map((child) => child.token.value) + .join("\n"), + }); + index += 1; + break; + } + + case "doctype": { + nodes.push({ kind: "doctype", value: token.value }); + index += 1; + break; + } + + case "keyword": { + const keyword = token.keyword ?? ""; + + if (keyword === "if" || keyword === "unless") { + const branches: IfBranch[] = [ + { + test: token.keywordValue ?? "", + negate: keyword === "unless", + children: transform(entry.children), + }, + ]; + + let cursor = index + 1; + while (cursor < tokenNodes.length) { + const next = (tokenNodes[cursor] as TokenNode).token; + if (next.keyword !== "else") break; + const value = (next.keywordValue ?? "").trim(); + if (value.startsWith("if")) { + branches.push({ + test: value.slice(2).trim(), + negate: false, + children: transform((tokenNodes[cursor] as TokenNode).children), + }); + } else { + branches.push({ + test: null, + negate: false, + children: transform((tokenNodes[cursor] as TokenNode).children), + }); + } + cursor += 1; + } + + nodes.push({ kind: "if", branches }); + index = cursor; + break; + } + + if (keyword === "each") { + const parsed = parseEach(token.keywordValue ?? ""); + nodes.push({ + kind: "each", + value: parsed.value, + key: parsed.key, + list: parsed.list, + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "while") { + nodes.push({ + kind: "while", + test: token.keywordValue ?? "", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "case") { + const cases: { test?: string; children: Node[] }[] = []; + for (const child of entry.children) { + const childToken = child.token; + if (childToken.keyword === "when") { + cases.push({ + test: childToken.keywordValue ?? "", + children: transform(child.children), + }); + } else if (childToken.keyword === "default") { + cases.push({ children: transform(child.children) }); + } + } + const caseNode: CaseNode = { + kind: "case", + subject: token.keywordValue ?? "", + cases, + }; + nodes.push(caseNode); + index += 1; + break; + } + + if (keyword === "mixin") { + const definition = /^([\w$-]+)\s*\(([\s\S]*)\)\s*$/.exec( + (token.keywordValue ?? "").trim(), + ); + nodes.push({ + kind: "mixin", + name: definition + ? (definition[1] as string) + : (token.keywordValue ?? "").trim(), + params: definition ? (definition[2] as string).trim() : "", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "mixin-call") { + nodes.push({ + kind: "mixin-call", + name: token.keywordValue ?? "", + args: token.text ?? "", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "include") { + nodes.push({ + kind: "include", + path: (token.keywordValue ?? "").replace(/^['"]|['"]$/g, ""), + children: [], + }); + index += 1; + break; + } + + if (keyword === "extends") { + nodes.push({ + kind: "extends", + path: (token.keywordValue ?? "").replace(/^['"]|['"]$/g, ""), + }); + index += 1; + break; + } + + if ( + keyword === "block" || + keyword === "append" || + keyword === "prepend" + ) { + nodes.push({ + kind: "block", + name: (token.keywordValue ?? "default").trim(), + mode: + keyword === "append" + ? "append" + : keyword === "prepend" + ? "prepend" + : "replace", + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "slot") { + nodes.push({ + kind: "slot", + name: (token.keywordValue ?? "default").trim(), + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "yield") { + nodes.push({ + kind: "yield", + name: (token.keywordValue ?? "default").trim(), + }); + index += 1; + break; + } + + if (keyword === "portal") { + nodes.push({ + kind: "portal", + name: (token.keywordValue ?? "default").trim().replace(/^['"]|['"]$/g, ""), + children: transform(entry.children), + }); + index += 1; + break; + } + + if (keyword === "props") { + nodes.push({ kind: "props", props: parseProps(entry.children) }); + index += 1; + break; + } + + if (keyword === "component" || keyword === "island") { + const props: Record<string, string> = {}; + const renders: { name: string; children: Node[] }[] = []; + for (const child of entry.children) { + if (child.token.keyword === "render") { + renders.push({ + name: (child.token.keywordValue ?? "").trim(), + children: transform(child.children), + }); + continue; + } + const assignment = parseComponentProps([child]); + Object.assign(props, assignment); + } + nodes.push({ + kind: keyword, + name: (token.keywordValue ?? "").trim(), + props, + renders, + }); + index += 1; + break; + } + + index += 1; + break; + } + + default: + index += 1; + break; + } + } + + return nodes; +} + +export interface ParsedTemplate { + nodes: Node[]; + frontmatter: string; +} + +/** Parses a template into its AST. */ +export function parse(tokens: Token[]): ParsedTemplate { + const frontmatterToken = tokens.find((token) => token.type === "frontmatter"); + const rest = tokens.filter((token) => token.type !== "frontmatter"); + return { + nodes: transform(buildTree(rest)), + frontmatter: frontmatterToken?.value ?? "", + }; +} + +/** Collects every `props` block of a template. */ +export function collectProps(nodes: Node[]): PropDeclaration[] { + const props: PropDeclaration[] = []; + for (const node of nodes) { + if (node.kind === "props") { + props.push(...node.props); + } + } + return props; +} + +/** Collects every named block of a template. */ +export function collectBlocks( + nodes: Node[], +): Map<string, { mode: "replace" | "append" | "prepend"; children: Node[] }> { + const blocks = new Map< + string, + { mode: "replace" | "append" | "prepend"; children: Node[] } + >(); + for (const node of nodes) { + if (node.kind === "block") { + blocks.set(node.name, { mode: node.mode, children: node.children }); + } + } + return blocks; +} + +/** Collects every mixin definition of a template. */ +export function collectMixins( + nodes: Node[], +): { name: string; params: string; children: Node[] }[] { + const mixins: { name: string; params: string; children: Node[] }[] = []; + for (const node of nodes) { + if (node.kind === "mixin") { + mixins.push({ + name: node.name, + params: node.params, + children: node.children, + }); + } + } + return mixins; +} diff --git a/views/engine/runtime.ts b/views/engine/runtime.ts new file mode 100644 index 0000000..a42a346 --- /dev/null +++ b/views/engine/runtime.ts @@ -0,0 +1,562 @@ +import { existsSync, statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { compileTemplate } from "./compiler.ts"; +import { lex } from "./lexer.ts"; +import { collectProps, parse } from "./parser.ts"; +import type { Node, PropDeclaration } from "./types.ts"; +import { isSpugComponentRender, type ComponentContext, type ComponentRegistry, type SpugComponentRender } from "../components.ts"; +import { compileCss, styleTag } from "../css/index.ts"; +import { isRaw, raw, styleValue } from "../raw.ts"; +import type { DeferredChunk } from "../../contracts/typegen/http/response.ts"; +import type { MuddleHooks } from "../hooks.ts"; + +/** + * Template runtime. + * + * Compiles templates, resolves includes and extends and provides the helpers + * used by the generated code. + */ + +export interface TemplateHelpers { + escapeHtml(value: string): string; + stringify(value: unknown): string; + /** Renders a value, raw values are never escaped. */ + render(value: unknown, escaped: boolean): string; + /** Transforms an object into the value of a style attribute. */ + style(value: unknown): string; + attr(name: string, value: unknown): string; + attrBoolean(name: string): string; + /** Merges the classes of a tag. */ + classes(values: unknown[]): string; +} + +export type CompiledFunction = ( + runtime: TemplateRuntime, + locals: Record<string, unknown>, +) => Promise<string>; + +export interface CompiledBlock { + mode: "replace" | "append" | "prepend"; + fn: CompiledFunction; +} + +export interface CompiledTemplate { + /** Absolute path of the template. */ + path: string; + template: CompiledFunction; + blocks: Map<string, CompiledBlock>; + mixins: Record<string, CompiledFunction>; + /** Props declared by the template. */ + props: PropDeclaration[]; + /** Frontmatter of the template. */ + frontmatter: string; + /** Frontmatter of the template, evaluated before the body. */ + frontmatterFn?: CompiledFunction; + /** Identifiers used by the template. */ + uses: string[]; + /** The assertions of the template, written in a `$$$` block. */ + tests: string[]; + /** Template this template extends, if any. */ + extends?: string; +} + +/** Context of a single render. */ +export interface RenderContext { + /** Locals of the render. */ + locals: Record<string, unknown>; + /** Route parameters of the render. */ + params: Record<string, string>; + /** Chunks rendered after the shell. */ + deferred: DeferredChunk[]; +} + +export interface TemplateRuntime { + helpers: TemplateHelpers; + /** File of the template, available in the frontmatter as `__muddle`. */ + file: { path: string; dir: string }; + getBlock(name: string, fallback: CompiledFunction): Promise<string>; + renderComponent( + name: string, + props: Record<string, unknown>, + ): Promise<string>; + renderIsland(name: string, props: Record<string, unknown>): Promise<string>; + /** Server components registered in the application. */ + components: ComponentRegistry; + /** Context of the current render. */ + context: RenderContext; +} + +export const helpers: TemplateHelpers = { + escapeHtml(value: string): string { + return String(value) + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }, + stringify(value: unknown): string { + if (value === null || value === undefined) return ""; + if (typeof value === "string") return value; + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (value instanceof Date) return value.toISOString(); + if (typeof value === "object") { + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + return String(value); + }, + attr(name: string, value: unknown): string { + if (value === false || value === null || value === undefined) return ""; + if (value === true) return ` ${name}="${name}"`; + return ` ${name}="${helpers.escapeHtml(helpers.stringify(value))}"`; + }, + attrBoolean(name: string): string { + return ` ${name}="${name}"`; + }, + render(value: unknown, escaped: boolean): string { + if (isRaw(value)) return value.html; + const text = helpers.stringify(value); + return escaped ? helpers.escapeHtml(text) : text; + }, + style(value: unknown): string { + return styleValue(value); + }, + classes(values: unknown[]): string { + return values + .filter( + (value) => + value !== undefined && + value !== null && + value !== false && + value !== "", + ) + .map((value) => String(value)) + .join(" "); + }, +}; + +interface CompiledArtifact { + template: CompiledFunction; + frontmatter?: CompiledFunction; + blocks: Record<string, CompiledBlock>; + mixins: Record<string, CompiledFunction>; + /** Identifiers used by the template. */ + uses: string[]; + /** The assertions of the template, written in a `$$$` block. */ + tests: string[]; +} + +export interface TemplateEngineOptions { + /** Directory holding the templates. */ + viewsDir: string; + /** Root directory of the project, template paths are resolved against it. */ + root?: string; + /** Directory holding the components, defaults to `viewsDir/components`. */ + componentsDir?: string; + /** Server components available to the templates. */ + components?: ComponentRegistry; + /** Layout templates wrapped around every page render. */ + globalLayouts?: string[]; + /** Contextual render expansions applied after Spug composition. */ + hooks?: MuddleHooks; +} + +/** Merges the blocks of a child template into the blocks of its parent. */ +export function mergeBlocks( + parent: Map<string, CompiledBlock>, + child: Map<string, CompiledBlock>, +): Map<string, CompiledBlock> { + const merged = new Map(parent); + for (const [name, block] of child) { + const existing = merged.get(name); + if (!existing) { + merged.set(name, block); + continue; + } + if (block.mode === "append") { + const parentFn = existing.fn; + const childFn = block.fn; + merged.set(name, { + mode: "replace", + fn: async (runtime, locals) => + `${await parentFn(runtime, locals)}${await childFn(runtime, locals)}`, + }); + continue; + } + if (block.mode === "prepend") { + const parentFn = existing.fn; + const childFn = block.fn; + merged.set(name, { + mode: "replace", + fn: async (runtime, locals) => + `${await childFn(runtime, locals)}${await parentFn(runtime, locals)}`, + }); + continue; + } + merged.set(name, block); + } + return merged; +} + +export class TemplateEngine { + private readonly cache = new Map< + string, + { mtime: number; compiled: CompiledTemplate } + >(); + + constructor(private readonly options: TemplateEngineOptions) {} + + /** Directory holding the templates. */ + get viewsDir(): string { + return this.options.viewsDir; + } + + /** Directory holding the components. */ + get componentsDir(): string { + return ( + this.options.componentsDir ?? join(this.options.viewsDir, "components") + ); + } + + /** Server components available to the templates. */ + get components(): ComponentRegistry { + return this.options.components ?? new Map(); + } + + /** Resolves a template path relative to the views directory. */ + resolvePath(templatePath: string, from?: string): string { + const candidates: string[] = []; + if (isAbsolute(templatePath)) { + candidates.push(templatePath); + } else { + if (from) candidates.push(resolve(dirname(from), templatePath)); + candidates.push(resolve(this.options.viewsDir, templatePath)); + if (this.options.root) { + candidates.push(resolve(this.options.root, templatePath)); + } + } + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + if (existsSync(`${candidate}.spug`)) return `${candidate}.spug`; + } + throw new Error(`Template not found: ${templatePath}`); + } + + /** Loads and compiles a template, using the cache when possible. */ + async load(templatePath: string, from?: string): Promise<CompiledTemplate> { + const path = this.resolvePath(templatePath, from); + const mtime = statSync(path).mtimeMs; + const cached = this.cache.get(path); + if (cached && cached.mtime === mtime) return cached.compiled; + + const compiled = await this.compile(path); + this.cache.set(path, { mtime, compiled }); + return compiled; + } + + /** Compiles a template without touching the cache. */ + async compile(path: string): Promise<CompiledTemplate> { + const source = await readFile(path, "utf8"); + const tokens = lex(source); + const parsed = parse(tokens); + + const frontmatter = await this.linkIncludes( + parsed.nodes, + parsed.frontmatter, + path, + ); + const extendsPath = parsed.nodes.find((node) => node.kind === "extends"); + const { source: compiledSource } = compileTemplate(parsed.nodes, { + path, + dir: dirname(path), + frontmatter, + }); + + const factory = new Function(compiledSource) as () => CompiledArtifact; + const artifact = factory(); + + const blocks = new Map<string, CompiledBlock>( + Object.entries(artifact.blocks), + ); + const props = collectProps(parsed.nodes); + + return { + path, + template: artifact.template, + frontmatterFn: artifact.frontmatter, + uses: artifact.uses ?? [], + tests: artifact.tests ?? [], + blocks, + mixins: artifact.mixins, + props, + frontmatter, + extends: extendsPath?.kind === "extends" ? extendsPath.path : undefined, + }; + } + + /** Renders a template with the given locals. */ + async render( + templatePath: string, + initialLocals: Record<string, unknown> = {}, + context?: Partial<RenderContext>, + ): Promise<string> { + const loaded = await this.load(templatePath); + const locals: Record<string, unknown> = { ...initialLocals }; + const compiled = loaded; + this.fillLocals(compiled, locals); + const renderContext: RenderContext = { + locals, + params: context?.params ?? {}, + deferred: context?.deferred ?? [], + }; + + let html: string; + if (compiled.extends) { + const parent = await this.load(compiled.extends, compiled.path); + const runtime = this.createRuntime( + compiled, + locals, + compiled.blocks, + renderContext, + ); + await this.runFrontmatter(compiled, locals, runtime); + this.fillLocals(parent, locals); + const blocks = mergeBlocks(parent.blocks, compiled.blocks); + const parentRuntime = this.createRuntime( + parent, + locals, + blocks, + renderContext, + ); + await this.runFrontmatter(parent, locals, parentRuntime); + html = await parent.template(parentRuntime, locals); + } else { + const blocks = mergeBlocks(compiled.blocks, new Map()); + const runtime = this.createRuntime(compiled, locals, blocks, renderContext); + await this.runFrontmatter(compiled, locals, runtime); + html = await compiled.template(runtime, locals); + } + for (const layout of this.options.globalLayouts ?? []) { + html = await this.renderLayout(layout, { ...locals, content: raw(html) }, renderContext); + } + return this.options.hooks + ? this.options.hooks.renderSpug({ html, path: loaded.path, locals }) + : html; + } + + private async renderLayout(path: string, locals: Record<string, unknown>, context: RenderContext): Promise<string> { + const compiled = await this.load(path); + const runtime = this.createRuntime(compiled, locals, compiled.blocks, context); + await this.runFrontmatter(compiled, locals, runtime); + return compiled.template(runtime, locals); + } + + /** + * Fills the missing identifiers of a template with `undefined`. + */ + private fillLocals( + compiled: CompiledTemplate, + locals: Record<string, unknown>, + ): void { + for (const name of compiled.uses ?? []) { + if (!(name in locals)) locals[name] = undefined; + } + } + + /** + * Evaluates the frontmatter of a template. + */ + private async runFrontmatter( + compiled: CompiledTemplate, + locals: Record<string, unknown>, + runtime: TemplateRuntime, + ): Promise<void> { + if (!compiled.frontmatterFn) return; + const values = await compiled.frontmatterFn(runtime, locals); + Object.assign(locals, values); + } + + /** Renders a component by name. */ + async renderComponent( + name: string, + props: Record<string, unknown>, + context?: Partial<RenderContext>, + ): Promise<string> { + const path = this.resolveComponent(name); + const compiled = await this.load(path); + props = { ...props }; + this.fillLocals(compiled, props); + const runtime = this.createRuntime( + compiled, + props, + compiled.blocks, + context, + ); + await this.runFrontmatter(compiled, props, runtime); + return compiled.template(runtime, props); + } + + /** Renders a component and marks it for client side hydration. */ + async renderIsland( + name: string, + props: Record<string, unknown>, + context?: Partial<RenderContext>, + ): Promise<string> { + const html = await this.renderComponent(name, props, context); + const payload = JSON.stringify(props).replace(/</g, "\\u003c"); + return `${html}<script type="application/json" data-muddle-island="${name}">${payload}</script>`; + } + + private resolveComponent(name: string): string { + if (isAbsolute(name) && existsSync(name)) return name; + const candidates = [ + join(this.componentsDir, `${name}.spug`), + join(this.componentsDir, name), + join(this.viewsDir, `${name}.spug`), + join(this.viewsDir, name), + ]; + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isFile()) { + return candidate; + } + } + throw new Error(`Component not found: ${name}`); + } + + private createRuntime( + compiled: CompiledTemplate, + locals: Record<string, unknown>, + blocks: Map<string, CompiledBlock>, + context?: Partial<RenderContext>, + ): TemplateRuntime { + const renderContext: RenderContext = { + locals, + params: context?.params ?? {}, + deferred: context?.deferred ?? [], + }; + + const componentContext: ComponentContext = { + defer: (id, content) => { + renderContext.deferred.push({ + id, + content: + typeof content === "string" ? Promise.resolve(content) : content, + }); + }, + locals, + params: renderContext.params, + }; + + let deferredCount = 0; + + const runtime: TemplateRuntime = { + helpers, + file: { path: compiled.path, dir: dirname(compiled.path) }, + getBlock: async (name, fallback) => { + const block = blocks.get(name); + if (!block) return fallback(runtime, locals); + return block.fn(runtime, locals); + }, + renderComponent: async (name, props) => { + const definition = this.components.get(name); + if (!definition) { + return this.renderComponent(name, props, renderContext); + } + if (definition.deferred) { + deferredCount += 1; + const id = `${name}-${deferredCount}`; + componentContext.defer( + id, + Promise.resolve(definition.component.render(props, componentContext)) + .then((output) => this.renderComponentOutput(output, props, renderContext, definition.requiresSpug)), + ); + return `<div data-muddle-deferred="${id}"></div>`; + } + return this.renderComponentOutput(await definition.component.render(props, componentContext), props, renderContext, definition.requiresSpug); + }, + renderIsland: (name, props) => + this.renderIsland(name, props, renderContext), + components: this.components, + context: renderContext, + }; + + return runtime; + } + + private async renderComponentOutput( + output: string | SpugComponentRender, + props: Record<string, unknown>, + context: Partial<RenderContext>, + requiresSpug = false, + ): Promise<string> { + if (!isSpugComponentRender(output)) { + if (requiresSpug) throw new Error("@Component renderers must return { spug, stcss } rather than raw HTML"); + return output; + } + const html = await this.renderComponent(output.spug, { ...props, ...(output.props ?? {}) }, context); + const styles = Array.isArray(output.stcss) ? output.stcss : output.stcss ? [output.stcss] : []; + const css = await Promise.all(styles.map(async (style) => { + const path = resolve(this.componentsDir, style); + return existsSync(path) ? compileCss(await readFile(path, "utf8")) : compileCss(style); + })); + return `${css.filter(Boolean).map(styleTag).join("")}${html}`; + } + + /** Inlines the included files and collects their frontmatter. */ + private async linkIncludes( + nodes: Node[], + frontmatter: string, + from: string, + ): Promise<string> { + let collected = frontmatter; + + for (const node of nodes) { + const children = childrenOf(node); + if (!children) continue; + + if (node.kind === "include") { + const path = this.resolvePath(node.path, from); + const source = await readFile(path, "utf8"); + const parsed = parse(lex(source)); + node.children = parsed.nodes; + const nested = await this.linkIncludes( + parsed.nodes, + parsed.frontmatter, + path, + ); + collected = `${collected}\n${nested}`; + continue; + } + + collected = await this.linkIncludes(children, collected, from); + } + + return collected; + } +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} diff --git a/views/engine/types.ts b/views/engine/types.ts new file mode 100644 index 0000000..4b55a42 --- /dev/null +++ b/views/engine/types.ts @@ -0,0 +1,244 @@ +export type TokenType = + | "tag" + | "text" + | "code" + | "out" + | "comment" + | "doctype" + | "keyword" + | "frontmatter" + | "test" + | "rawtext"; + +export interface RawAttribute { + name: string; + value?: string; + kind: "string" | "expression" | "boolean"; +} + +/** A single line of a template, already split into its semantic parts. */ +export interface Token { + type: TokenType; + /** Indentation width, used to build the tree. */ + indent: number; + /** Line number in the source file, used for error messages. */ + line: number; + /** Raw text of the line. */ + value: string; + /** Tag name, only for `tag` tokens. */ + name?: string; + /** Attributes, only for `tag` tokens. */ + attributes?: RawAttribute[]; + /** Inline text following the tag, only for `tag` tokens. */ + text?: string; + /** Buffered expression of the tag, e.g. `p= title`. */ + output?: string; + /** Keyword name, only for `keyword` tokens. */ + keyword?: string; + /** Keyword payload (`each item in items`), only for `keyword` tokens. */ + keywordValue?: string; + /** Whether the tag is followed by a block of raw text (`p.`). */ + blockText?: boolean; + /** A generated relation identity declared with `tag@name()` in SPUG. */ + anchor?: { name: string; expression?: string }; + /** Whether a comment is silent (`//-`). */ + silent?: boolean; + /** Whether an output expression is escaped (`=` vs `!=`). */ + escaped?: boolean; +} + +export type TextPart = + | { type: "literal"; value: string } + | { type: "expression"; code: string; escaped: boolean }; + +export interface PropDeclaration { + name: string; + /** Type expression as written in the template. */ + type: string; + optional: boolean; + /** Default value expression, when declared with `=`. */ + defaultValue?: string; +} + +export interface ElementNode { + kind: "element"; + name: string; + attributes: RawAttribute[]; + children: Node[]; + selfClosing: boolean; + /** A generated relation identity declared with `tag@name()` in SPUG. */ + anchor?: { name: string; expression?: string }; +} + +export interface TextNode { + kind: "text"; + parts: TextPart[]; +} + +export interface RawTextNode { + kind: "rawtext"; + value: string; +} + +export interface CodeNode { + kind: "code"; + code: string; +} + +export interface OutNode { + kind: "out"; + code: string; + escaped: boolean; +} + +export interface CommentNode { + kind: "comment"; + value: string; + silent: boolean; + children: Node[]; +} + +export interface DoctypeNode { + kind: "doctype"; + value: string; +} + +export interface IfBranch { + /** `null` marks the `else` branch. */ + test: string | null; + negate: boolean; + children: Node[]; +} + +export interface IfNode { + kind: "if"; + branches: IfBranch[]; +} + +export interface EachNode { + kind: "each"; + value: string; + key?: string; + list: string; + children: Node[]; +} + +export interface WhileNode { + kind: "while"; + test: string; + children: Node[]; +} + +export interface CaseNode { + kind: "case"; + subject: string; + cases: { test?: string; children: Node[] }[]; +} + +export interface MixinDefNode { + kind: "mixin"; + name: string; + params: string; + children: Node[]; +} + +export interface MixinCallNode { + kind: "mixin-call"; + name: string; + args: string; + children: Node[]; +} + +export interface IncludeNode { + kind: "include"; + path: string; + /** Included file content, resolved by the linker. */ + children: Node[]; +} + +export interface ExtendsNode { + kind: "extends"; + path: string; +} + +export interface BlockNode { + kind: "block"; + name: string; + mode: "replace" | "append" | "prepend"; + children: Node[]; +} + +export interface SlotNode { + kind: "slot"; + name: string; + children: Node[]; +} + +export interface YieldNode { + kind: "yield"; + name: string; +} + +export interface PropsNode { + kind: "props"; + props: PropDeclaration[]; +} + +export interface ComponentNode { + kind: "component" | "island"; + name: string; + /** Prop name to expression. */ + props: Record<string, string>; + /** Renderers that are applied inline by the component. */ + renders: { name: string; children: Node[] }[]; +} + +/** + * A block of assertions. + */ +export interface TestNode { + kind: "test"; + /** The assertions of the block. */ + code: string; +} + +/** + * A renderer that is applied inline. + */ +export interface RenderNode { + kind: "render"; + /** Name of the prop the renderer is handed to. */ + name: string; + children: Node[]; +} + +export interface PortalNode { + kind: "portal"; + name: string; + children: Node[]; +} + +export type Node = + | ElementNode + | TextNode + | RawTextNode + | CodeNode + | OutNode + | CommentNode + | DoctypeNode + | IfNode + | EachNode + | WhileNode + | CaseNode + | MixinDefNode + | MixinCallNode + | IncludeNode + | ExtendsNode + | BlockNode + | SlotNode + | YieldNode + | PropsNode + | ComponentNode + | TestNode + | RenderNode + | PortalNode; diff --git a/views/hooks.ts b/views/hooks.ts new file mode 100644 index 0000000..e8192c7 --- /dev/null +++ b/views/hooks.ts @@ -0,0 +1,246 @@ +import type { CssDeclaration, CssNode } from "./css/parser.ts"; + +export interface SpugHookContext { + html: string; + path: string; + locals: Record<string, unknown>; +} + +export type SpugExpansion = (context: SpugHookContext) => string | Promise<string>; + +export interface StcssValueHookContext { + name: string; + inner: string; + resolve(value: string): string; +} + +export type StcssValueExpansion = (context: StcssValueHookContext) => string | undefined; + +export interface StcssCalculationHookContext { + name: string; + args?: string; + resolve(value: string): string; +} + +export type StcssCalculationExpansion = (context: StcssCalculationHookContext) => string | undefined; + +export interface StcssDeclarationHookContext { + selector: string; + declaration: CssDeclaration; + declarations: CssDeclaration[]; + depth: number; + resolve(value: string): string; +} + +export type StcssDeclarationExpansion = (context: StcssDeclarationHookContext) => string[] | undefined; + +export interface StcssAtRuleHookContext { + name: string; + params: string; + selector: string; + depth: number; + children: CssNode[]; + resolve(value: string): string; + renderChildren(): string; +} + +export type StcssAtRuleExpansion = (context: StcssAtRuleHookContext) => string | undefined; + +/** + * A contextual registry for transformations performed while Spug renders or + * STCSS compiles. Registrations are scoped to an explicit registry instance, + * so applications can opt into a transformation without changing global state. + */ +export class MuddleHooks { + private readonly spugExpansions = new Map<string, SpugExpansion>(); + private readonly valueExpansions = new Map<string, StcssValueExpansion>(); + private readonly calculationExpansions = new Map<string, StcssCalculationExpansion>(); + private readonly declarationExpansions: StcssDeclarationExpansion[] = []; + private readonly atRuleExpansions = new Map<string, StcssAtRuleExpansion>(); + + spug(name: string, expansion: SpugExpansion): this { + this.spugExpansions.set(name, expansion); + return this; + } + + stcssValue(name: string, expansion: StcssValueExpansion): this { + this.valueExpansions.set(name, expansion); + return this; + } + + stcssCalculation(name: string, expansion: StcssCalculationExpansion): this { + this.calculationExpansions.set(name.replace(/^\$/, ""), expansion); + return this; + } + + stcssDeclaration(expansion: StcssDeclarationExpansion): this { + this.declarationExpansions.push(expansion); + return this; + } + + stcssAtRule(name: string, expansion: StcssAtRuleExpansion): this { + this.atRuleExpansions.set(name, expansion); + return this; + } + + async renderSpug(context: SpugHookContext): Promise<string> { + let html = context.html; + for (const expansion of this.spugExpansions.values()) { + html = await expansion({ ...context, html }); + } + return html; + } + + expandStcssValue(name: string, inner: string, resolve: (value: string) => string): string | undefined { + return this.valueExpansions.get(name)?.({ name, inner, resolve }); + } + + expandStcssCalculation(name: string, args: string | undefined, resolve: (value: string) => string): string | undefined { + return this.calculationExpansions.get(name.replace(/^\$/, ""))?.({ name, args, resolve }); + } + + expandStcssCalculationTokens(value: string, resolve: (value: string) => string): string { + return value.replace(/\$([A-Za-z][\w-]*)\b(?!\s*\()/g, (token, name: string) => + this.expandStcssCalculation(name, undefined, resolve) ?? token); + } + + expandStcssDeclarations(context: Omit<StcssDeclarationHookContext, "declaration">): string[] { + return context.declarations.flatMap((declaration) => this.declarationExpansions.flatMap((expansion) => + expansion({ ...context, declaration }) ?? [])); + } + + expandStcssAtRule(context: StcssAtRuleHookContext): string | undefined { + return this.atRuleExpansions.get(context.name)?.(context); + } +} + +export interface HookBuilder { + spug(name: string, expansion: SpugExpansion): void; + stcss: { + value(name: string, expansion: StcssValueExpansion): void; + calculation(name: string, expansion: StcssCalculationExpansion): void; + declaration(expansion: StcssDeclarationExpansion): void; + atRule(name: string, expansion: StcssAtRuleExpansion): void; + }; +} + +/** Creates an application-local hook registry through contextual builders. */ +export function defineHooks(build: (hooks: HookBuilder) => void, defaults = true): MuddleHooks { + const hooks = createHooks(defaults); + build({ + spug: (name, expansion) => { hooks.spug(name, expansion); }, + stcss: { + value: (name, expansion) => { hooks.stcssValue(name, expansion); }, + calculation: (name, expansion) => { hooks.stcssCalculation(name, expansion); }, + declaration: (expansion) => { hooks.stcssDeclaration(expansion); }, + atRule: (name, expansion) => { hooks.stcssAtRule(name, expansion); }, + }, + }); + return hooks; +} + +export function createHooks(defaults = true): MuddleHooks { + const hooks = new MuddleHooks(); + if (defaults) registerBuiltinStcssExpansions(hooks); + return hooks; +} + +function registerBuiltinStcssExpansions(hooks: MuddleHooks): void { + hooks.stcssCalculation("center", ({ args, resolve }) => { + const size = args?.trim(); + return size ? `calc(50% - (${resolve(size)} / 2))` : "50%"; + }); + hooks.stcssCalculation("offset", ({ args, resolve }) => { + const values = splitList(args ?? "").map((value) => resolve(value.trim())).filter(Boolean); + if (values.length === 0) return "0px"; + return values.length === 1 ? values[0] : `calc(${values.join(" + ")})`; + }); + hooks.stcssCalculation("pad", ({ args, resolve }) => { + const multiplier = args?.trim(); + return multiplier ? `calc(var(--muddle-pad, 1rem) * ${resolve(multiplier)})` : "var(--muddle-pad, 1rem)"; + }); + hooks.stcssValue("freeform-gradient", ({ inner, resolve }) => { + return splitList(resolve(inner)) + .filter((item) => item.trim().length > 0) + .map((item) => { + const tokens = gradientTokens(item.trim()); + if (tokens.length !== 3 && tokens.length !== 4) throw new Error(`Invalid freeform-gradient item "${item.trim()}"`); + const [color, x, y, radius] = tokens as [string, string, string, string?]; + return radius !== undefined + ? `radial-gradient(circle at ${x} ${y}, ${color}, ${color} calc(${radius} * 0.97), transparent ${radius})` + : `radial-gradient(circle farthest-side at ${x} ${y}, ${color}, transparent)`; + }) + .join(", "); + }); + hooks.stcssDeclaration(({ selector, declaration, declarations, depth, resolve }) => { + if (declaration.property !== "border-radius" || !resolve(declaration.value).startsWith("concentric") || !selector) return undefined; + const outer = resolve(declaration.value).slice("concentric".length).trim() || "var(--concentric-radius, 1rem)"; + declaration.value = outer; + const spacing = concentricSpacing(declarations, resolve); + const childRadius = spacing.length > 0 ? `calc(${[outer, ...spacing].map((token) => `(${token})`).join(" - ")})` : "inherit"; + const indent = "\t".repeat(depth); + return [`${indent}${selector} > * {\n${indent}\tborder-radius: ${childRadius};\n${indent}}`]; + }); + hooks.stcssDeclaration(({ declaration, declarations }) => { + if (declaration.property !== "position" || (declaration.value !== "fixed-center" && declaration.value !== "page-center")) return undefined; + declaration.value = declaration.value === "fixed-center" ? "fixed" : "absolute"; + const set = (property: string, value: string) => { + const current = declarations.find((entry) => entry.property === property); + if (current) current.value = value; + else declarations.push({ property, value }); + }; + set("left", "50%"); + set("top", "50%"); + const transform = declarations.find((entry) => entry.property === "transform"); + if (transform) transform.value = `translate(-50%, -50%) ${transform.value}`; + else declarations.push({ property: "transform", value: "translate(-50%, -50%)" }); + return undefined; + }); + hooks.stcssAtRule("concentric", ({ params, selector, depth, resolve }) => { + if (!selector) return ""; + const indent = "\t".repeat(depth); + const radius = resolve(params.trim() || "1rem"); + return `${indent}${selector} {\n${indent}\tborder-radius: ${radius};\n${indent}}\n${indent}${selector} > * {\n${indent}\tborder-radius: inherit;\n${indent}}`; + }); +} + +function concentricSpacing(declarations: CssDeclaration[], resolve: (value: string) => string): string[] { + const find = (property: string): string | undefined => declarations + .filter((declaration) => declaration.property === property || declaration.property === `${property}-top` || declaration.property === `${property}-left` || declaration.property === `${property}-block-start` || declaration.property === `${property}-inline-start`) + .flatMap((declaration) => gradientTokens(resolve(declaration.value).trim())) + .find((value) => value.length > 0 && value !== "solid" && value !== "none" && value !== "0"); + const spacing = [find("padding"), find("border-width") ?? find("border")].filter((value): value is string => value !== undefined); + return spacing; +} + +function gradientTokens(item: string): string[] { + const tokens: string[] = []; + let current = ""; + let depth = 0; + for (const char of item) { + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (/\s/.test(char) && depth === 0) { + if (current) tokens.push(current); + current = ""; + } else current += char; + } + if (current) tokens.push(current); + return tokens; +} + +function splitList(value: string): string[] { + const items: string[] = []; + let current = ""; + let depth = 0; + for (const char of value) { + if (char === "(") depth += 1; + if (char === ")") depth -= 1; + if (char === "," && depth === 0) { + items.push(current); + current = ""; + } else current += char; + } + items.push(current); + return items; +} diff --git a/views/index.ts b/views/index.ts new file mode 100644 index 0000000..77d243e --- /dev/null +++ b/views/index.ts @@ -0,0 +1,18 @@ +export * from "./api.ts"; +export * from "./components.ts"; +export * from "./css/index.ts"; +export { + collectIdentifiers, + collectUses, + compileTemplate, + mixinParamNames, + transformFrontmatter, + type CompiledSource, + type CompileOptions, +} from "./engine/compiler.ts"; +export * from "./engine/lexer.ts"; +export * from "./engine/parser.ts"; +export * from "./engine/runtime.ts"; +export * from "./engine/types.ts"; +export * from "./integrations.ts"; +export * from "./markdown.ts"; diff --git a/views/integrations.ts b/views/integrations.ts new file mode 100644 index 0000000..a4594ae --- /dev/null +++ b/views/integrations.ts @@ -0,0 +1,134 @@ +import { readdir, readFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import type { + ContentSchema, + Integration, + IntegrationContext, +} from "../contracts/index.ts"; +import { compileCss, type CompileOptions } from "./css/index.ts"; +import { markdownLoader } from "./markdown.ts"; +import { sassLoader, type SassLoaderOptions } from "./css/sass.ts"; + +export { shadcnIntegration, type ShadcnIntegrationOptions } from "../integrations/shadcn.ts"; + +export interface MarkdownIntegrationOptions { + /** Name of the collection, defaults to `notes`. */ + name?: string; + /** Directory of the collection, relative to the project root. */ + dir?: string; + /** Schema of the frontmatter. */ + schema?: ContentSchema; +} + +/** + * Markdown integration. + * + * The integration registers the markdown loader and adds the collection of + * the content directory to the project. + */ +export function markdownIntegration( + options: MarkdownIntegrationOptions = {}, +): Integration { + const name = options.name ?? "notes"; + const dir = options.dir ?? "content/notes"; + + return { + name: "markdown", + setup(context: IntegrationContext) { + context.loader("markdown", markdownLoader()); + const collections = context.config.collections ?? []; + if (collections.some((collection) => collection.name === name)) return; + collections.push({ + name, + dir, + loader: "markdown", + schema: options.schema, + }); + context.config.collections = collections; + }, + }; +} + +export interface StcssIntegrationOptions extends CompileOptions { + /** Directory of the stylesheets, relative to the project root. */ + dir?: string; + /** Route the compiled css is served at. */ + route?: string; +} + +/** + * Css integration. + * + * The integration compiles the structured stylesheets of the project and + * serves them at a single route, the browser caches the result. + */ +export function stcssIntegration( + options: StcssIntegrationOptions = {}, +): Integration { + const route = options.route ?? "/_muddle/styles.css"; + + return { + name: "stcss", + async setup(context: IntegrationContext) { + context.loader("stcss", { + name: "stcss", + extensions: [".stcss"], + load(file) { + return { data: {}, content: compileCss(file.source, options) }; + }, + }); + + const dir = resolve(context.config.root, options.dir ?? "styles"); + if (!existsSync(dir)) return; + + const files = (await readdir(dir)).filter((file) => + file.endsWith(".stcss"), + ); + const parts: string[] = []; + for (const file of files.sort()) { + const source = await readFile(join(dir, file), "utf8"); + parts.push(compileCss(source, options)); + } + const css = parts.join("\n"); + + context.route("GET", route, () => + new Response(css, { + headers: { + "content-type": "text/css; charset=utf-8", + "cache-control": "public, max-age=3600", + }, + }), + ); + }, + }; +} + +/** Registers Sass files as loaders and serves their compiled CSS alongside STCSS. */ +export function sassIntegration(options: SassLoaderOptions = {}): Integration { + const route = "/_muddle/sass.css"; + return { + name: "sass", + async setup(context) { + const loader = sassLoader(options); + context.loader("sass", loader); + const dir = resolve(context.config.root, options.dir ?? "styles"); + if (!existsSync(dir)) return; + const files = await sassFiles(dir); + context.route("GET", route, async () => { + const css = await Promise.all(files.filter((file) => !file.split("/").at(-1)?.startsWith("_")).map(async (file) => (await loader.load({ path: join(dir, file), relativePath: `styles/${file}`, source: await readFile(join(dir, file), "utf8") })).content ?? "")); + return new Response(css.join("\n"), { headers: { "content-type": "text/css; charset=utf-8" } }); + }); + }, + }; +} + +async function sassFiles(dir: string, prefix = ""): Promise<string[]> { + const files: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const relative = `${prefix}${entry.name}`; + if (entry.isDirectory()) files.push(...await sassFiles(join(dir, entry.name), `${relative}/`)); + else if (/\.s[ac]ss$/i.test(entry.name)) files.push(relative); + } + return files.sort(); +} diff --git a/views/language.ts b/views/language.ts new file mode 100644 index 0000000..73e8820 --- /dev/null +++ b/views/language.ts @@ -0,0 +1,24 @@ +export interface LanguageCompletion { label: string; detail: string; insertText: string } + +export const SPUG_COMPLETIONS: LanguageCompletion[] = [ + { label: "props", detail: "Typed component props", insertText: "props\n ${1:name}: ${2:string}" }, + { label: "javascript!", detail: "Embedded TypeScript block", insertText: "javascript!\n ${1:const value = true;}\nend" }, + { label: "component", detail: "SPUG component", insertText: "component ${1:Name}" }, + { label: "portal", detail: "Named view-transition boundary", insertText: "portal ${1:main}\n ${0}" }, + { label: "anchor", detail: "Relation identity", insertText: "${1:li}@${2:item}() ${3:content}" }, +]; + +export const STCSS_COMPLETIONS: LanguageCompletion[] = [ + { label: "@responsive", detail: "Mobile-first breakpoint", insertText: "@responsive ${1:md} {\n ${2}\n}" }, + { label: "@concentric", detail: "Inherited child radius", insertText: "@concentric ${1:1rem};" }, + { label: "border-radius: concentric", detail: "Concentric radii for children", insertText: "border-radius: concentric ${1:1rem};" }, + { label: "freeform-gradient", detail: "Stacked radial gradients", insertText: "background-image: freeform-gradient(${1:#7c3aed} ${2:10%} ${3:50%}, ${4:f00} ${5:70%} ${6:40%});" }, + { label: "$center", detail: "Center a known size", insertText: "$center(${1:30rem})" }, + { label: "$offset", detail: "Compose positional offsets", insertText: "$offset(${1:$center(30rem)}, ${2:$pad})" }, + { label: "$pad", detail: "Shared spacing token", insertText: "$pad(${1:1})" }, + { label: "position: fixed-center", detail: "Fixed viewport center", insertText: "position: fixed-center;" }, + { label: "position: page-center", detail: "Absolute containing-block center", insertText: "position: page-center;" }, + { label: "@predict", detail: "Prerendered geometry", insertText: "@predict max-width max-height transform;" }, + { label: "$relate", detail: "Predicted anchor metric", insertText: "$relate[${1:width}](d\"@${2:item}\")" }, + { label: "javascript!", detail: "Compile-time values", insertText: "javascript!\n const ${1:space} = ${2:'1rem'};\nend" }, +]; diff --git a/views/markdown.ts b/views/markdown.ts new file mode 100644 index 0000000..acb8171 --- /dev/null +++ b/views/markdown.ts @@ -0,0 +1,276 @@ +import type { Loader, LoaderFile } from "../contracts/index.ts"; + +/** + * Markdown support. + * + * A small, dependency free renderer that covers the subset used by content + * collections: headings, paragraphs, lists, quotes, code and the usual inline + * markup. The loader turns a markdown file into frontmatter and html. + */ + +export interface Frontmatter { + [key: string]: unknown; +} + +/** Parses the frontmatter of a markdown file. */ +export function parseFrontmatter(source: string): { + frontmatter: Frontmatter; + body: string; +} { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source); + if (!match) return { frontmatter: {}, body: source }; + const frontmatter = parseYaml(match[1] as string); + return { frontmatter, body: source.slice(match[0].length) }; +} + +/** Parses a small subset of yaml: scalars, lists and nested maps. */ +export function parseYaml(source: string): Frontmatter { + const result: Frontmatter = {}; + const lines = source.split("\n"); + let index = 0; + + while (index < lines.length) { + const line = (lines[index] as string).trimEnd(); + index += 1; + if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue; + + const separator = line.indexOf(":"); + if (separator === -1) continue; + const key = line.slice(0, separator).trim(); + const raw = line.slice(separator + 1).trim(); + + if (raw.length === 0) { + const items: unknown[] = []; + while (index < lines.length) { + const next = (lines[index] as string).trim(); + if (next.length === 0) { + index += 1; + continue; + } + if (!next.startsWith("- ")) break; + items.push(parseScalar(next.slice(2).trim())); + index += 1; + } + result[key] = items.length > 0 ? items : ""; + continue; + } + + result[key] = parseScalar(raw); + } + + return result; +} + +function parseScalar(raw: string): unknown { + if (raw.startsWith("[") && raw.endsWith("]")) { + return raw + .slice(1, -1) + .split(",") + .map((entry) => parseScalar(entry.trim())) + .filter((entry) => entry !== ""); + } + if (raw === "true") return true; + if (raw === "false") return false; + if (raw === "null") return null; + if (raw !== "" && !Number.isNaN(Number(raw))) return Number(raw); + return raw.replace(/^['"]|['"]$/g, ""); +} + +/** Escapes the html entities of a text. */ +export function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """); +} + +/** + * Renders markdown to html. + * + * The renderer is intentionally small, it covers the constructs used by the + * content collections of a project. + */ +export function renderMarkdown(source: string): string { + const lines = source.replace(/\r\n/g, "\n").split("\n"); + const output: string[] = []; + let index = 0; + + while (index < lines.length) { + const line = lines[index] as string; + + if (line.trim().length === 0) { + index += 1; + continue; + } + + if (/^\s*\$\$\s*$/.test(line)) { + const formula: string[] = []; + index += 1; + while (index < lines.length && !/^\s*\$\$\s*$/.test(lines[index] as string)) { + formula.push(lines[index] as string); + index += 1; + } + if (index < lines.length) index += 1; + output.push(`<div class="muddle-math">${renderLatex(formula.join("\n"), true)}</div>`); + continue; + } + + const fence = /^\s*```(\w*)\s*$/.exec(line); + if (fence) { + const language = fence[1] ?? ""; + const code: string[] = []; + index += 1; + while ( + index < lines.length && + !/^\s*```\s*$/.test(lines[index] as string) + ) { + code.push(lines[index] as string); + index += 1; + } + index += 1; + const className = language ? ` class="language-${language}"` : ""; + output.push( + `<pre><code${className}>${escapeHtml(code.join("\n"))}</code></pre>`, + ); + continue; + } + + if (/^\s*@@@\s*$/.test(line)) { + const graph: string[] = []; + index += 1; + while (index < lines.length && !/^\s*@@@\s*$/.test(lines[index] as string)) { + graph.push(lines[index] as string); + index += 1; + } + index += 1; + output.push(`<pre class="mermaid" data-muddle-animate="true">${escapeHtml(graph.join("\n"))}</pre>`); + continue; + } + + const heading = /^(#{1,6})\s+(.*)$/.exec(line); + if (heading) { + const level = (heading[1] as string).length; + output.push( + `<h${level}>${renderInline(heading[2] as string)}</h${level}>`, + ); + index += 1; + continue; + } + + if (/^\s*(?:---+|\*\*\*+|___+)\s*$/.test(line)) { + output.push("<hr>"); + index += 1; + continue; + } + + const quote = /^\s*>\s?(.*)$/.exec(line); + if (quote) { + const quoted: string[] = []; + while (index < lines.length) { + const next = /^\s*>\s?(.*)$/.exec(lines[index] as string); + if (!next) break; + quoted.push(next[1] as string); + index += 1; + } + output.push( + `<blockquote>${renderMarkdown(quoted.join("\n"))}</blockquote>`, + ); + continue; + } + + const bullet = /^\s*[-*+]\s+(.*)$/.exec(line); + const ordered = /^\s*\d+[.)]\s+(.*)$/.exec(line); + if (bullet || ordered) { + const tag = bullet ? "ul" : "ol"; + const items: unknown[] = []; + while (index < lines.length) { + const current = lines[index] as string; + const match = bullet + ? /^\s*[-*+]\s+(.*)$/.exec(current) + : /^\s*\d+[.)]\s+(.*)$/.exec(current); + if (!match) break; + items.push(match[1] as string); + index += 1; + } + const rendered = items + .map((item) => `<li>${renderInline(String(item))}</li>`) + .join(""); + output.push(`<${tag}>${rendered}</${tag}>`); + continue; + } + + const paragraph: string[] = []; + while (index < lines.length) { + const current = lines[index] as string; + if (current.trim().length === 0) break; + if (/^(#{1,6})\s+/.test(current)) break; + if (/^\s*([-*+]\s|\d+[.)]\s|>)/.test(current)) break; + paragraph.push(current.trim()); + index += 1; + } + output.push(`<p>${renderInline(paragraph.join(" "))}</p>`); + } + + return output.join("\n"); +} + +/** Renders the inline markup of a text. */ +export function renderInline(source: string): string { + const codes: string[] = []; + const maths: string[] = []; + let text = source.replace(/`([^`]+)`/g, (_match, code: string) => { + codes.push(code); + return `\uE000${codes.length - 1}\uE000`; + }); + text = text.replace(/(?<!\\)\$([^$\n]+?)\$/g, (_match, formula: string) => { + maths.push(renderLatex(formula)); + return `\uE001${maths.length - 1}\uE001`; + }); + + text = escapeHtml(text); + text = text.replace( + /!\[([^\]]*)\]\(([^)\s]+)\)/g, + '<img src="$2" alt="$1" loading="lazy">', + ); + text = text.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2">$1</a>'); + text = text.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); + text = text.replace(/__([^_]+)__/g, "<strong>$1</strong>"); + text = text.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<em>$2</em>"); + text = text.replace(/~~([^~]+)~~/g, "<del>$1</del>"); + + text = text.replace(/\uE000(\d+)\uE000/g, (_match, index: string) => { + return `<code>${escapeHtml(codes[Number(index)] ?? "")}</code>`; + }); + return text.replace(/\uE001(\d+)\uE001/g, (_match, index: string) => maths[Number(index)] ?? ""); +} + +/** Renders a trusted LaTex formula to KaTeX HTML and MathML. */ +export function renderLatex(source: string, displayMode = false): string { + return escapeHtml(`${displayMode ? "$$" : "$"}${source}${displayMode ? "$$" : "$"}`); +} + +export interface MarkdownLoaderOptions { + /** Directory the loader scans, relative to the project root. */ + dir?: string; +} + +/** + * Loader for markdown files. + * + * The loader turns a markdown file into its frontmatter and its rendered + * html, exactly like a content collection needs it. + */ +export function markdownLoader(_options: MarkdownLoaderOptions = {}): Loader { + return { + name: "markdown", + extensions: [".md", ".markdown"], + load(file: LoaderFile) { + const { frontmatter, body } = parseFrontmatter(file.source); + return { + data: frontmatter, + content: renderMarkdown(body), + }; + }, + }; +} diff --git a/views/raw.ts b/views/raw.ts new file mode 100644 index 0000000..d4dcc49 --- /dev/null +++ b/views/raw.ts @@ -0,0 +1,96 @@ +/** + * Raw values. + * + * A raw value is html that is already rendered, it is never escaped. The + * wrapper marks the values that come from a trusted source, e.g. the html of + * a markdown collection. + */ +export class Raw { + constructor(readonly html: string) {} + + toString(): string { + return this.html; + } +} + +/** Marks html as pre-rendered, it is never escaped. */ +export function raw(html: string): Raw { + return new Raw(html); +} + +/** `true` when the value is a raw html value. */ +export function isRaw(value: unknown): value is Raw { + return value instanceof Raw; +} + +/** Properties that do not take a unit. */ +const UNITLESS = new Set([ + "animation-iteration-count", + "border-image-outset", + "border-image-slice", + "border-image-width", + "box-flex", + "box-flex-group", + "box-ordinal-group", + "column-count", + "columns", + "flex", + "flex-grow", + "flex-positive", + "flex-shrink", + "flex-negative", + "flex-order", + "font-weight", + "grid-area", + "grid-column", + "grid-column-end", + "grid-column-start", + "grid-row", + "grid-row-end", + "grid-row-start", + "line-clamp", + "line-height", + "opacity", + "order", + "orphans", + "scale", + "tab-size", + "widows", + "z-index", + "zoom", +]); + +/** Converts a camel case property into its css name. */ +export function cssPropertyName(property: string): string { + if (property.startsWith("--")) return property; + return property.replace(/[A-Z]/g, (char) => `-${char.toLowerCase()}`); +} + +/** + * Transforms an object into the value of a style attribute. + * + * The keys become css properties, the numbers become pixels unless the + * property does not take a unit. + */ +export function styleValue(value: unknown): string { + if (value === null || value === undefined || value === false) return ""; + if (typeof value === "string" || typeof value === "number") return String(value); + if (isRaw(value)) return value.html; + if (typeof value !== "object") return ""; + + const parts: string[] = []; + for (const [property, entry] of Object.entries(value as Record<string, unknown>)) { + if (entry === null || entry === undefined || entry === false) continue; + const name = cssPropertyName(property); + if (typeof entry === "number") { + parts.push(`${name}: ${UNITLESS.has(name) ? entry : `${entry}px`}`); + continue; + } + if (typeof entry === "object") { + parts.push(`${name}: ${styleValue(entry)}`); + continue; + } + parts.push(`${name}: ${String(entry)}`); + } + return parts.join("; "); +} diff --git a/views/stage.ts b/views/stage.ts new file mode 100644 index 0000000..00a784b --- /dev/null +++ b/views/stage.ts @@ -0,0 +1,69 @@ +export interface KeyframeStep { + o: Record<string, string | number>; +} + +export interface ScrollSceneOptions { + selector: string; + keyframes: Record<string, KeyframeStep | Record<string, string | number>>; + range?: string; + timeline?: "view" | "scroll" | string; + animation?: string; + duration?: string; + easing?: string; + fill?: string; +} + +export interface TransitionPortalOptions { + name: string; + old?: Record<string, string | number>; + new?: Record<string, string | number>; + duration?: string; + easing?: string; +} + +function declarations(values: Record<string, string | number>): string { + return Object.entries(values).map(([property, value]) => `${property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}: ${value};`).join(" "); +} + +/** Builds declarative native CSS scenes and view transitions without a client runtime. */ +export class StageManager { + private readonly scenes: ScrollSceneOptions[] = []; + private readonly portals: TransitionPortalOptions[] = []; + + scene(name: string, options: Omit<ScrollSceneOptions, "animation">): this { + this.scenes.push({ ...options, animation: name }); + return this; + } + + portal(name: string, options: Omit<TransitionPortalOptions, "name"> = {}): this { + this.portals.push({ name, ...options }); + return this; + } + + css(): string { + return [ + ...this.scenes.flatMap((scene) => { + const animation = scene.animation ?? "muddle-scene"; + const timeline = scene.timeline ?? "view"; + return [ + `@keyframes ${animation} { ${Object.entries(scene.keyframes).map(([offset, step]) => { + const values = "to" in step && typeof step.to === "object" ? step.to : step as Record<string, string | number>; + return `${offset} { ${declarations(values)} }`; + }).join(" ")} }`, + `${scene.selector} { animation-name: ${animation}; animation-duration: ${scene.duration ?? "1ms"}; animation-timing-function: ${scene.easing ?? "linear"}; animation-fill-mode: ${scene.fill ?? "both"}; animation-timeline: ${timeline === "view" ? "view()" : timeline === "scroll" ? "scroll()" : timeline}; animation-range: ${scene.range ?? "entry 0% cover 100%"}; }`, + ]; + }), + ...this.portals.flatMap((portal) => { + const timing = `animation-duration: ${portal.duration ?? "240ms"}; animation-timing-function: ${portal.easing ?? "ease"};`; + return [ + portal.old ? `::view-transition-old(${portal.name}) { ${timing} ${declarations(portal.old)} }` : "", + portal.new ? `::view-transition-new(${portal.name}) { ${timing} ${declarations(portal.new)} }` : "", + ].filter(Boolean); + }), + ].join("\n"); + } +} + +export function stage(): StageManager { + return new StageManager(); +} diff --git a/views/testing.ts b/views/testing.ts new file mode 100644 index 0000000..498f7a9 --- /dev/null +++ b/views/testing.ts @@ -0,0 +1,263 @@ +import { existsSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { join, relative, resolve } from "node:path"; +import { TemplateEngine } from "./engine/runtime.ts"; +import { lex } from "./engine/lexer.ts"; +import { parse } from "./engine/parser.ts"; +import type { Node } from "./engine/types.ts"; +import type { ComponentRegistry } from "./components.ts"; + +/** The helpers that are available inside a `$$$` block. */ +export const ASSERTION_HELPERS = ` +window.muddle = window.muddle || {}; +window.muddle.assert = function (condition, message) { + if (!condition) { + throw new Error(message || "assertion failed"); + } +}; +window.muddle.assertText = function (selector, expected, message) { + const element = document.querySelector(selector); + const actual = element ? element.textContent.trim() : null; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual), + ); + } +}; +window.muddle.assertHtml = function (selector, expected, message) { + const element = document.querySelector(selector); + const actual = element ? element.innerHTML.trim() : null; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected " + JSON.stringify(expected) + ", got " + JSON.stringify(actual), + ); + } +}; +window.muddle.assertCount = function (selector, expected, message) { + const actual = document.querySelectorAll(selector).length; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected " + expected + " elements, got " + actual, + ); + } +}; +window.muddle.assertExists = function (selector, message) { + if (!document.querySelector(selector)) { + throw new Error((message || selector) + ": the element does not exist"); + } +}; +window.muddle.assertMissing = function (selector, message) { + if (document.querySelector(selector)) { + throw new Error((message || selector) + ": the element should not exist"); + } +}; +window.muddle.assertAttribute = function (selector, name, expected, message) { + const element = document.querySelector(selector); + const actual = element ? element.getAttribute(name) : null; + if (actual !== expected) { + throw new Error( + (message || selector) + ": expected the attribute " + name + " to be " + JSON.stringify(expected) + ", got " + JSON.stringify(actual), + ); + } +}; +window.muddle.assertState = function (selector, state, message) { + const element = document.querySelector(selector); + const actual = element ? element.getAttribute("data-state") : null; + if (actual !== state) { + throw new Error( + (message || selector) + ": expected the state " + JSON.stringify(state) + ", got " + JSON.stringify(actual), + ); + } +}; +const assert = window.muddle.assert; +const assertText = window.muddle.assertText; +const assertHtml = window.muddle.assertHtml; +const assertCount = window.muddle.assertCount; +const assertExists = window.muddle.assertExists; +const assertMissing = window.muddle.assertMissing; +const assertAttribute = window.muddle.assertAttribute; +const assertState = window.muddle.assertState; +`; + +export interface TemplateTestOptions { + /** Root directory of the project. */ + root: string; + /** Directories scanned for templates. */ + viewRoots: string[]; + /** Locals handed to the templates. */ + locals?: Record<string, unknown>; + /** Directory of the components. */ + componentsDir?: string; + /** Server components available to the templates. */ + components?: ComponentRegistry; +} + +export interface TemplateTest { + /** Absolute path of the template. */ + path: string; + /** Path of the template, relative to the root. */ + key: string; + /** The assertions of the template. */ + assertions: string; +} + +/** Reads the assertions of a template. */ +export function readAssertions(source: string): string { + const parsed = parse(lex(source)); + const blocks: string[] = []; + collect(parsed.nodes, blocks); + return blocks.join("\n"); +} + +function collect(nodes: Node[], blocks: string[]): void { + for (const node of nodes) { + if (node.kind === "test") { + if (node.code.trim().length > 0) blocks.push(node.code); + continue; + } + const children = childrenOf(node); + if (children) collect(children, blocks); + } +} + +function childrenOf(node: Node): Node[] | undefined { + switch (node.kind) { + case "element": + case "each": + case "while": + case "mixin": + case "mixin-call": + case "include": + case "block": + case "slot": + case "comment": + return node.children; + case "if": + return node.branches.flatMap((branch) => branch.children); + default: + return undefined; + } +} + +/** Lists the templates of the project. */ +export async function listTemplates(dirs: string[]): Promise<string[]> { + const files: string[] = []; + for (const dir of dirs) { + if (!existsSync(dir)) continue; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...(await listTemplates([path]))); + continue; + } + if (entry.name.endsWith(".spug")) files.push(path); + } + } + return files; +} + +/** Collects the templates that carry assertions. */ +export async function collectTemplateTests( + options: TemplateTestOptions, +): Promise<TemplateTest[]> { + const tests: TemplateTest[] = []; + for (const file of await listTemplates(options.viewRoots)) { + const source = await readFile(file, "utf8"); + const assertions = readAssertions(source); + if (assertions.trim().length === 0) continue; + tests.push({ + path: file, + key: relative(options.root, file).replace(/\\/g, "/"), + assertions, + }); + } + return tests; +} + +export interface TemplateTestFailure { + /** Path of the template. */ + key: string; + /** The failure of the template. */ + error: Error; +} + +export interface TemplateTestRun { + /** The templates that were tested. */ + tested: TemplateTest[]; + /** The failures of the templates. */ + failures: TemplateTestFailure[]; + /** `true` when the browser could not be started. */ + skipped: boolean; + /** The reason of the skip. */ + reason?: string; +} + +/** + * Runs the tests of the templates. + * + * The templates are rendered on the server, the assertions are evaluated in a + * browser. When playwright is not installed the run is skipped, the tests of + * the templates do not fail the build. + */ +export async function runTemplateTests( + options: TemplateTestOptions, +): Promise<TemplateTestRun> { + const tests = await collectTemplateTests(options); + const failures: TemplateTestFailure[] = []; + if (tests.length === 0) return { tested: [], failures, skipped: false }; + + let chromium: typeof import("playwright").chromium; + try { + ({ chromium } = await import("playwright")); + } catch (error) { + return { + tested: tests, + failures, + skipped: true, + reason: `playwright is not installed: ${(error as Error).message}`, + }; + } + + const engine = new TemplateEngine({ + viewsDir: options.viewRoots[0] ?? options.root, + componentsDir: options.componentsDir, + root: options.root, + components: options.components, + }); + + const browser = await chromium.launch(); + try { + const page = await browser.newPage(); + for (const test of tests) { + const html = await engine.render( + test.path, + options.locals ?? {}, + ); + await page.setContent(html, { waitUntil: "load" }); + try { + await page.evaluate( + `(async () => {\n${ASSERTION_HELPERS}\n${test.assertions}\n})()`, + ); + } catch (error) { + failures.push({ + key: test.key, + error: error as Error, + }); + } + } + } finally { + await browser.close(); + } + + return { tested: tests, failures, skipped: false }; +} + +/** The engine of the project, used by the runner. */ +export function createTestEngine(options: TemplateTestOptions): TemplateEngine { + return new TemplateEngine({ + viewsDir: options.viewRoots[0] ?? resolve(options.root), + componentsDir: options.componentsDir, + root: options.root, + components: options.components, + }); +}
    ${result.item.title}`; + + for (const match of result.matches) { + if (match.key === "title") continue; + + const indices = match.indices.sort((a, b) => Math.abs(a[1] - a[0] - searchVal.length) - Math.abs(b[1] - b[0] - searchVal.length)).slice(0, MAX_RESULTS); + const value = match.value; + + for (const ind of indices) { + const start = Math.max(0, ind[0] - TEASER_SIZE); + const end = Math.min(value.length - 1, ind[1] + TEASER_SIZE); + output += "" + + value.substring(start, ind[0]) + + `${value.substring(ind[0], ind[1] + 1)}` + + value.substring(ind[1] + 1, end) + + ""; + } + + if (match.indices.length > MAX_RESULTS) { + const moreMatchesText = document.getElementById("more-matches-text").textContent; + output += `${moreMatchesText}`.replace("$MATCHES", `+${match.indices.length - MAX_RESULTS}`); + } + } + return output + "