From ba5b61b9cb441f8432b69e2d701a17dd017d440c Mon Sep 17 00:00:00 2001 From: abrulic Date: Sun, 19 Jul 2026 21:40:00 +0200 Subject: [PATCH] initial documentation --- README.md | 13 + biome.json | 2 +- docs/.dockerignore | 87 + docs/.env.example | 4 + docs/.github/PULL_REQUEST_TEMPLATE.md | 42 + docs/.github/workflows/ci.yml | 127 + docs/.github/workflows/pr-close.yml | 25 + .../workflows/publish-documentation.yml | 93 + docs/.gitignore | 89 + docs/.npmrc | 6 + docs/.vscode/extensions.json | 3 + docs/.vscode/settings.json | 47 + docs/CODE_OF_CONDUCT.md | 128 + docs/Dockerfile | 38 + docs/LICENSE | 21 + docs/README.md | 66 + docs/app/components/backdrop.tsx | 16 + .../components/code-block/code-block-diff.ts | 45 + .../code-block/code-block-elements.tsx | 66 + .../code-block/code-block-parser.ts | 53 + .../code-block-syntax-highlighter.ts | 157 + docs/app/components/code-block/code-block.tsx | 20 + .../app/components/code-block/copy-button.tsx | 38 + .../code-block/tests/code-block-diff.test.ts | 75 + .../tests/code-block-parser.test.ts | 67 + .../code-block-syntax-highlighter.test.ts | 110 + .../command-k/components/command-k.tsx | 135 + .../command-k/components/empty-state.tsx | 29 + .../command-k/components/keyboard-hint.tsx | 21 + .../components/results-footer-note.tsx | 15 + .../command-k/components/results-footer.tsx | 28 + .../command-k/components/search-history.tsx | 122 + .../command-k/components/search-input.tsx | 47 + .../command-k/components/search-result.tsx | 86 + .../command-k/components/trigger-button.tsx | 45 + .../command-k/create-search-index.ts | 127 + .../command-k/hooks/use-debounce.ts | 12 + .../command-k/hooks/use-fuzzy-search.ts | 76 + .../hooks/use-keyboard-navigation.ts | 73 + .../command-k/hooks/use-modal-state.ts | 16 + .../command-k/hooks/use-search-history.ts | 71 + .../components/command-k/hooks/use-search.ts | 62 + docs/app/components/command-k/search-types.ts | 26 + .../components/github-contribute-links.tsx | 28 + docs/app/components/header.tsx | 19 + docs/app/components/icon-link.tsx | 28 + docs/app/components/logo.tsx | 15 + docs/app/components/mdx-wrapper.tsx | 26 + docs/app/components/modal.tsx | 98 + docs/app/components/page-mdx-article.tsx | 21 + docs/app/components/page-navigation.tsx | 75 + .../components/sidebar/build-breadcrumbs.ts | 41 + .../components/sidebar/desktop-sidebar.tsx | 14 + .../sidebar/mobile-sidebar-context.tsx | 29 + .../app/components/sidebar/mobile-sidebar.tsx | 96 + .../components/sidebar/sidebar-content.tsx | 44 + docs/app/components/sidebar/sidebar-items.tsx | 111 + docs/app/components/sidebar/sidebar.tsx | 32 + .../sidebar/tests/build-breadcrumbs.test.ts | 105 + docs/app/components/table-of-content.tsx | 148 + docs/app/components/theme-toggle.tsx | 31 + docs/app/components/versions-dropdown.tsx | 55 + docs/app/entry.client.tsx | 53 + docs/app/entry.server.tsx | 79 + docs/app/env.server.ts | 67 + docs/app/hooks/use-active-heading-id.ts | 77 + .../use-documentation-layout-loader-data.ts | 12 + docs/app/hooks/use-mobile-view.ts | 21 + docs/app/hooks/use-previous-next-pages.ts | 46 + docs/app/hooks/use-scroll-lock.ts | 39 + docs/app/localization/i18n.server.test.ts | 21 + docs/app/localization/i18n.server.ts | 18 + docs/app/localization/i18n.ts | 12 + docs/app/localization/resource.ts | 30 + docs/app/root.tsx | 189 + docs/app/routes.ts | 16 + docs/app/routes/$.tsx | 43 + docs/app/routes/documentation-homepage.tsx | 44 + docs/app/routes/documentation-layout.tsx | 46 + docs/app/routes/documentation-page.tsx | 63 + docs/app/routes/index.tsx | 159 + docs/app/routes/llms[.]txt.ts | 22 + docs/app/routes/resource.locales.ts | 40 + docs/app/routes/robots[.]txt.ts | 21 + docs/app/routes/search.ts | 27 + docs/app/routes/sitemap-index[.]xml.ts | 23 + docs/app/routes/sitemap.$lang[.]xml.ts | 27 + docs/app/server/context.ts | 31 + docs/app/server/index.ts | 12 + docs/app/server/search-index.ts | 36 + docs/app/services/client-hints.tsx | 47 + docs/app/tailwind.css | 300 + docs/app/ui/accordion.tsx | 111 + docs/app/ui/alert.tsx | 80 + docs/app/ui/anchor-tag.tsx | 9 + docs/app/ui/breadcrumbs.tsx | 55 + docs/app/ui/icon-button.tsx | 24 + docs/app/ui/icon/icon.tsx | 45 + docs/app/ui/icon/icons/icon.svg | 32 + docs/app/ui/icon/icons/types.ts | 33 + docs/app/ui/info-alert.tsx | 16 + docs/app/ui/inline-code.tsx | 20 + docs/app/ui/kbd.tsx | 21 + docs/app/ui/link/index.ts | 1 + docs/app/ui/link/link.tsx | 20 + docs/app/ui/link/useEnhancedTo.ts | 52 + docs/app/ui/list-item.tsx | 14 + docs/app/ui/ordered-list.tsx | 25 + docs/app/ui/strong-text.tsx | 6 + docs/app/ui/title.tsx | 53 + docs/app/ui/warning-alert.tsx | 16 + .../utils/create-github-contribution-links.ts | 26 + docs/app/utils/create-sidebar-tree.ts | 59 + docs/app/utils/css.ts | 4 + .../utils/extract-heading-tree-from-mdx.ts | 127 + docs/app/utils/flatten-sidebar.ts | 10 + docs/app/utils/fonts.ts | 164 + docs/app/utils/get-domain.ts | 5 + docs/app/utils/get-page-slug.tsx | 5 + docs/app/utils/http.ts | 23 + docs/app/utils/llms-txt-builder.ts | 54 + docs/app/utils/load-content-collections.ts | 37 + docs/app/utils/load-content.ts | 25 + docs/app/utils/local-storage.ts | 12 + docs/app/utils/parse-search-params.ts | 15 + docs/app/utils/path-builders.ts | 45 + docs/app/utils/scroll-into-view.ts | 21 + docs/app/utils/seo.ts | 30 + docs/app/utils/split-slug.ts | 16 + docs/app/utils/tests/css.test.ts | 39 + docs/app/utils/tests/local-storage.test.ts | 62 + docs/app/utils/theme.ts | 17 + docs/app/utils/version-resolvers.ts | 51 + docs/app/utils/versions.ts | 4 + docs/biome.json | 68 + docs/content-collections.ts | 101 + docs/content/01-introduction.mdx | 44 + .../02-getting-started/01-installation.mdx | 58 + .../02-getting-started/02-quick-start.mdx | 77 + .../03-what-it-generates.mdx | 76 + docs/content/02-getting-started/index.md | 3 + .../03-core-concepts/01-how-it-works.mdx | 41 + .../03-core-concepts/02-environments.mdx | 41 + .../03-core-concepts/03-config-file.mdx | 76 + docs/content/03-core-concepts/index.md | 3 + docs/content/04-commands/01-init.mdx | 57 + docs/content/04-commands/02-generate.mdx | 39 + docs/content/04-commands/03-rollback.mdx | 47 + docs/content/04-commands/index.md | 3 + .../01-health-checks-and-rollbacks.mdx | 52 + .../content/05-guides/02-multiple-regions.mdx | 47 + .../05-guides/03-database-migrations.mdx | 44 + .../04-custom-domains-cloudflare.mdx | 74 + .../05-guides/05-provisioning-and-secrets.mdx | 54 + docs/content/05-guides/index.md | 3 + docs/content/06-reference/01-cli-flags.mdx | 52 + .../06-reference/02-config-reference.mdx | 87 + docs/content/06-reference/index.md | 3 + docs/content/_index.mdx | 43 + docs/env.d.ts | 3 + docs/fly.toml | 20 + docs/knip.json | 20 + docs/lefthook.yml | 12 + docs/package.json | 107 + docs/pnpm-lock.yaml | 8289 +++++++++++++++++ docs/public/favicon.ico | Bin 0 -> 15406 bytes .../static/images/docs-template-home.png | Bin 0 -> 165759 bytes .../static/images/docs-template-photo.png | Bin 0 -> 123355 bytes .../static/images/package-logo-1200x630.png | Bin 0 -> 41841 bytes docs/public/static/images/package-logo.png | Bin 0 -> 12573 bytes docs/react-router.config.ts | 9 + .../fonts/dyna-puff/DynaPuff-Bold.ttf | Bin 0 -> 90356 bytes .../fonts/dyna-puff/DynaPuff-Medium.ttf | Bin 0 -> 90432 bytes .../fonts/dyna-puff/DynaPuff-Regular.ttf | Bin 0 -> 90344 bytes .../fonts/dyna-puff/DynaPuff-SemiBold.ttf | Bin 0 -> 90448 bytes docs/resources/fonts/inter/Inter-Black.ttf | Bin 0 -> 344764 bytes .../fonts/inter/Inter-BlackItalic.ttf | Bin 0 -> 348612 bytes docs/resources/fonts/inter/Inter-Bold.ttf | Bin 0 -> 344028 bytes .../fonts/inter/Inter-BoldItalic.ttf | Bin 0 -> 347948 bytes .../resources/fonts/inter/Inter-ExtraBold.ttf | Bin 0 -> 344800 bytes .../fonts/inter/Inter-ExtraBoldItalic.ttf | Bin 0 -> 348896 bytes .../fonts/inter/Inter-ExtraLight.ttf | Bin 0 -> 343516 bytes .../fonts/inter/Inter-ExtraLightItalic.ttf | Bin 0 -> 347352 bytes docs/resources/fonts/inter/Inter-Italic.ttf | Bin 0 -> 346580 bytes docs/resources/fonts/inter/Inter-Light.ttf | Bin 0 -> 343440 bytes .../fonts/inter/Inter-LightItalic.ttf | Bin 0 -> 347300 bytes docs/resources/fonts/inter/Inter-Medium.ttf | Bin 0 -> 342936 bytes .../fonts/inter/Inter-MediumItalic.ttf | Bin 0 -> 347000 bytes docs/resources/fonts/inter/Inter-Regular.ttf | Bin 0 -> 342732 bytes docs/resources/fonts/inter/Inter-SemiBold.ttf | Bin 0 -> 343640 bytes .../fonts/inter/Inter-SemiBoldItalic.ttf | Bin 0 -> 347616 bytes docs/resources/fonts/inter/Inter-Thin.ttf | Bin 0 -> 342944 bytes .../fonts/inter/Inter-ThinItalic.ttf | Bin 0 -> 346976 bytes docs/resources/fonts/space/Space.woff2 | Bin 0 -> 42000 bytes docs/resources/icons/arrow-left.svg | 1 + docs/resources/icons/arrow-right.svg | 1 + docs/resources/icons/bot.svg | 1 + docs/resources/icons/chevron-down.svg | 1 + docs/resources/icons/chevron-right.svg | 1 + docs/resources/icons/clipboard-check.svg | 1 + docs/resources/icons/clipboard-copy.svg | 1 + docs/resources/icons/clock.svg | 1 + docs/resources/icons/code.svg | 1 + docs/resources/icons/file-text.svg | 1 + docs/resources/icons/ghost.svg | 1 + docs/resources/icons/github.svg | 1 + docs/resources/icons/hash.svg | 1 + docs/resources/icons/info.svg | 1 + docs/resources/icons/menu.svg | 1 + docs/resources/icons/moon.svg | 1 + docs/resources/icons/palette.svg | 1 + docs/resources/icons/pilcrow.svg | 1 + docs/resources/icons/rocket.svg | 1 + docs/resources/icons/search.svg | 1 + docs/resources/icons/shield-check.svg | 1 + docs/resources/icons/sun-moon.svg | 1 + docs/resources/icons/sun.svg | 1 + docs/resources/icons/trash-2.svg | 1 + docs/resources/icons/triangle-alert.svg | 1 + docs/resources/icons/x.svg | 1 + docs/resources/icons/zap.svg | 1 + docs/resources/locales/bs/common.json | 66 + docs/resources/locales/en/common.json | 66 + docs/scripts/generate-docs.ts | 250 + docs/scripts/setup.ts | 97 + docs/tests/setup.browser.tsx | 76 + docs/tsconfig.json | 28 + docs/vite.config.ts | 49 + docs/vitest.config.ts | 16 + docs/vitest.workspace.ts | 40 + vitest.config.ts | 11 + 231 files changed, 16928 insertions(+), 1 deletion(-) create mode 100644 docs/.dockerignore create mode 100644 docs/.env.example create mode 100644 docs/.github/PULL_REQUEST_TEMPLATE.md create mode 100644 docs/.github/workflows/ci.yml create mode 100644 docs/.github/workflows/pr-close.yml create mode 100644 docs/.github/workflows/publish-documentation.yml create mode 100644 docs/.gitignore create mode 100644 docs/.npmrc create mode 100644 docs/.vscode/extensions.json create mode 100644 docs/.vscode/settings.json create mode 100644 docs/CODE_OF_CONDUCT.md create mode 100644 docs/Dockerfile create mode 100644 docs/LICENSE create mode 100644 docs/README.md create mode 100644 docs/app/components/backdrop.tsx create mode 100644 docs/app/components/code-block/code-block-diff.ts create mode 100644 docs/app/components/code-block/code-block-elements.tsx create mode 100644 docs/app/components/code-block/code-block-parser.ts create mode 100644 docs/app/components/code-block/code-block-syntax-highlighter.ts create mode 100644 docs/app/components/code-block/code-block.tsx create mode 100644 docs/app/components/code-block/copy-button.tsx create mode 100644 docs/app/components/code-block/tests/code-block-diff.test.ts create mode 100644 docs/app/components/code-block/tests/code-block-parser.test.ts create mode 100644 docs/app/components/code-block/tests/code-block-syntax-highlighter.test.ts create mode 100644 docs/app/components/command-k/components/command-k.tsx create mode 100644 docs/app/components/command-k/components/empty-state.tsx create mode 100644 docs/app/components/command-k/components/keyboard-hint.tsx create mode 100644 docs/app/components/command-k/components/results-footer-note.tsx create mode 100644 docs/app/components/command-k/components/results-footer.tsx create mode 100644 docs/app/components/command-k/components/search-history.tsx create mode 100644 docs/app/components/command-k/components/search-input.tsx create mode 100644 docs/app/components/command-k/components/search-result.tsx create mode 100644 docs/app/components/command-k/components/trigger-button.tsx create mode 100644 docs/app/components/command-k/create-search-index.ts create mode 100644 docs/app/components/command-k/hooks/use-debounce.ts create mode 100644 docs/app/components/command-k/hooks/use-fuzzy-search.ts create mode 100644 docs/app/components/command-k/hooks/use-keyboard-navigation.ts create mode 100644 docs/app/components/command-k/hooks/use-modal-state.ts create mode 100644 docs/app/components/command-k/hooks/use-search-history.ts create mode 100644 docs/app/components/command-k/hooks/use-search.ts create mode 100644 docs/app/components/command-k/search-types.ts create mode 100644 docs/app/components/github-contribute-links.tsx create mode 100644 docs/app/components/header.tsx create mode 100644 docs/app/components/icon-link.tsx create mode 100644 docs/app/components/logo.tsx create mode 100644 docs/app/components/mdx-wrapper.tsx create mode 100644 docs/app/components/modal.tsx create mode 100644 docs/app/components/page-mdx-article.tsx create mode 100644 docs/app/components/page-navigation.tsx create mode 100644 docs/app/components/sidebar/build-breadcrumbs.ts create mode 100644 docs/app/components/sidebar/desktop-sidebar.tsx create mode 100644 docs/app/components/sidebar/mobile-sidebar-context.tsx create mode 100644 docs/app/components/sidebar/mobile-sidebar.tsx create mode 100644 docs/app/components/sidebar/sidebar-content.tsx create mode 100644 docs/app/components/sidebar/sidebar-items.tsx create mode 100644 docs/app/components/sidebar/sidebar.tsx create mode 100644 docs/app/components/sidebar/tests/build-breadcrumbs.test.ts create mode 100644 docs/app/components/table-of-content.tsx create mode 100644 docs/app/components/theme-toggle.tsx create mode 100644 docs/app/components/versions-dropdown.tsx create mode 100644 docs/app/entry.client.tsx create mode 100644 docs/app/entry.server.tsx create mode 100644 docs/app/env.server.ts create mode 100644 docs/app/hooks/use-active-heading-id.ts create mode 100644 docs/app/hooks/use-documentation-layout-loader-data.ts create mode 100644 docs/app/hooks/use-mobile-view.ts create mode 100644 docs/app/hooks/use-previous-next-pages.ts create mode 100644 docs/app/hooks/use-scroll-lock.ts create mode 100644 docs/app/localization/i18n.server.test.ts create mode 100644 docs/app/localization/i18n.server.ts create mode 100644 docs/app/localization/i18n.ts create mode 100644 docs/app/localization/resource.ts create mode 100644 docs/app/root.tsx create mode 100644 docs/app/routes.ts create mode 100644 docs/app/routes/$.tsx create mode 100644 docs/app/routes/documentation-homepage.tsx create mode 100644 docs/app/routes/documentation-layout.tsx create mode 100644 docs/app/routes/documentation-page.tsx create mode 100644 docs/app/routes/index.tsx create mode 100644 docs/app/routes/llms[.]txt.ts create mode 100644 docs/app/routes/resource.locales.ts create mode 100644 docs/app/routes/robots[.]txt.ts create mode 100644 docs/app/routes/search.ts create mode 100644 docs/app/routes/sitemap-index[.]xml.ts create mode 100644 docs/app/routes/sitemap.$lang[.]xml.ts create mode 100644 docs/app/server/context.ts create mode 100644 docs/app/server/index.ts create mode 100644 docs/app/server/search-index.ts create mode 100644 docs/app/services/client-hints.tsx create mode 100644 docs/app/tailwind.css create mode 100644 docs/app/ui/accordion.tsx create mode 100644 docs/app/ui/alert.tsx create mode 100644 docs/app/ui/anchor-tag.tsx create mode 100644 docs/app/ui/breadcrumbs.tsx create mode 100644 docs/app/ui/icon-button.tsx create mode 100644 docs/app/ui/icon/icon.tsx create mode 100644 docs/app/ui/icon/icons/icon.svg create mode 100644 docs/app/ui/icon/icons/types.ts create mode 100644 docs/app/ui/info-alert.tsx create mode 100644 docs/app/ui/inline-code.tsx create mode 100644 docs/app/ui/kbd.tsx create mode 100644 docs/app/ui/link/index.ts create mode 100644 docs/app/ui/link/link.tsx create mode 100644 docs/app/ui/link/useEnhancedTo.ts create mode 100644 docs/app/ui/list-item.tsx create mode 100644 docs/app/ui/ordered-list.tsx create mode 100644 docs/app/ui/strong-text.tsx create mode 100644 docs/app/ui/title.tsx create mode 100644 docs/app/ui/warning-alert.tsx create mode 100644 docs/app/utils/create-github-contribution-links.ts create mode 100644 docs/app/utils/create-sidebar-tree.ts create mode 100644 docs/app/utils/css.ts create mode 100644 docs/app/utils/extract-heading-tree-from-mdx.ts create mode 100644 docs/app/utils/flatten-sidebar.ts create mode 100644 docs/app/utils/fonts.ts create mode 100644 docs/app/utils/get-domain.ts create mode 100644 docs/app/utils/get-page-slug.tsx create mode 100644 docs/app/utils/http.ts create mode 100644 docs/app/utils/llms-txt-builder.ts create mode 100644 docs/app/utils/load-content-collections.ts create mode 100644 docs/app/utils/load-content.ts create mode 100644 docs/app/utils/local-storage.ts create mode 100644 docs/app/utils/parse-search-params.ts create mode 100644 docs/app/utils/path-builders.ts create mode 100644 docs/app/utils/scroll-into-view.ts create mode 100644 docs/app/utils/seo.ts create mode 100644 docs/app/utils/split-slug.ts create mode 100644 docs/app/utils/tests/css.test.ts create mode 100644 docs/app/utils/tests/local-storage.test.ts create mode 100644 docs/app/utils/theme.ts create mode 100644 docs/app/utils/version-resolvers.ts create mode 100644 docs/app/utils/versions.ts create mode 100644 docs/biome.json create mode 100644 docs/content-collections.ts create mode 100644 docs/content/01-introduction.mdx create mode 100644 docs/content/02-getting-started/01-installation.mdx create mode 100644 docs/content/02-getting-started/02-quick-start.mdx create mode 100644 docs/content/02-getting-started/03-what-it-generates.mdx create mode 100644 docs/content/02-getting-started/index.md create mode 100644 docs/content/03-core-concepts/01-how-it-works.mdx create mode 100644 docs/content/03-core-concepts/02-environments.mdx create mode 100644 docs/content/03-core-concepts/03-config-file.mdx create mode 100644 docs/content/03-core-concepts/index.md create mode 100644 docs/content/04-commands/01-init.mdx create mode 100644 docs/content/04-commands/02-generate.mdx create mode 100644 docs/content/04-commands/03-rollback.mdx create mode 100644 docs/content/04-commands/index.md create mode 100644 docs/content/05-guides/01-health-checks-and-rollbacks.mdx create mode 100644 docs/content/05-guides/02-multiple-regions.mdx create mode 100644 docs/content/05-guides/03-database-migrations.mdx create mode 100644 docs/content/05-guides/04-custom-domains-cloudflare.mdx create mode 100644 docs/content/05-guides/05-provisioning-and-secrets.mdx create mode 100644 docs/content/05-guides/index.md create mode 100644 docs/content/06-reference/01-cli-flags.mdx create mode 100644 docs/content/06-reference/02-config-reference.mdx create mode 100644 docs/content/06-reference/index.md create mode 100644 docs/content/_index.mdx create mode 100644 docs/env.d.ts create mode 100644 docs/fly.toml create mode 100644 docs/knip.json create mode 100644 docs/lefthook.yml create mode 100644 docs/package.json create mode 100644 docs/pnpm-lock.yaml create mode 100644 docs/public/favicon.ico create mode 100644 docs/public/static/images/docs-template-home.png create mode 100644 docs/public/static/images/docs-template-photo.png create mode 100644 docs/public/static/images/package-logo-1200x630.png create mode 100644 docs/public/static/images/package-logo.png create mode 100644 docs/react-router.config.ts create mode 100644 docs/resources/fonts/dyna-puff/DynaPuff-Bold.ttf create mode 100644 docs/resources/fonts/dyna-puff/DynaPuff-Medium.ttf create mode 100644 docs/resources/fonts/dyna-puff/DynaPuff-Regular.ttf create mode 100644 docs/resources/fonts/dyna-puff/DynaPuff-SemiBold.ttf create mode 100644 docs/resources/fonts/inter/Inter-Black.ttf create mode 100644 docs/resources/fonts/inter/Inter-BlackItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-Bold.ttf create mode 100644 docs/resources/fonts/inter/Inter-BoldItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-ExtraBold.ttf create mode 100644 docs/resources/fonts/inter/Inter-ExtraBoldItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-ExtraLight.ttf create mode 100644 docs/resources/fonts/inter/Inter-ExtraLightItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-Italic.ttf create mode 100644 docs/resources/fonts/inter/Inter-Light.ttf create mode 100644 docs/resources/fonts/inter/Inter-LightItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-Medium.ttf create mode 100644 docs/resources/fonts/inter/Inter-MediumItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-Regular.ttf create mode 100644 docs/resources/fonts/inter/Inter-SemiBold.ttf create mode 100644 docs/resources/fonts/inter/Inter-SemiBoldItalic.ttf create mode 100644 docs/resources/fonts/inter/Inter-Thin.ttf create mode 100644 docs/resources/fonts/inter/Inter-ThinItalic.ttf create mode 100644 docs/resources/fonts/space/Space.woff2 create mode 100644 docs/resources/icons/arrow-left.svg create mode 100644 docs/resources/icons/arrow-right.svg create mode 100644 docs/resources/icons/bot.svg create mode 100644 docs/resources/icons/chevron-down.svg create mode 100644 docs/resources/icons/chevron-right.svg create mode 100644 docs/resources/icons/clipboard-check.svg create mode 100644 docs/resources/icons/clipboard-copy.svg create mode 100644 docs/resources/icons/clock.svg create mode 100644 docs/resources/icons/code.svg create mode 100644 docs/resources/icons/file-text.svg create mode 100644 docs/resources/icons/ghost.svg create mode 100644 docs/resources/icons/github.svg create mode 100644 docs/resources/icons/hash.svg create mode 100644 docs/resources/icons/info.svg create mode 100644 docs/resources/icons/menu.svg create mode 100644 docs/resources/icons/moon.svg create mode 100644 docs/resources/icons/palette.svg create mode 100644 docs/resources/icons/pilcrow.svg create mode 100644 docs/resources/icons/rocket.svg create mode 100644 docs/resources/icons/search.svg create mode 100644 docs/resources/icons/shield-check.svg create mode 100644 docs/resources/icons/sun-moon.svg create mode 100644 docs/resources/icons/sun.svg create mode 100644 docs/resources/icons/trash-2.svg create mode 100644 docs/resources/icons/triangle-alert.svg create mode 100644 docs/resources/icons/x.svg create mode 100644 docs/resources/icons/zap.svg create mode 100644 docs/resources/locales/bs/common.json create mode 100644 docs/resources/locales/en/common.json create mode 100644 docs/scripts/generate-docs.ts create mode 100644 docs/scripts/setup.ts create mode 100644 docs/tests/setup.browser.tsx create mode 100644 docs/tsconfig.json create mode 100644 docs/vite.config.ts create mode 100644 docs/vitest.config.ts create mode 100644 docs/vitest.workspace.ts create mode 100644 vitest.config.ts diff --git a/README.md b/README.md index 696c656..0f4187c 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,19 @@ You can verify all of this: the code is source-available (see [`src/auth.ts`](src/auth.ts)), and releases ship with npm provenance. Full details and revocation steps are in [SECURITY.md](SECURITY.md). +## Documentation + +Full documentation lives in [`docs/`](docs/) — a self-contained docs site +(React Router v7 + content-collections, built from the +[code-forge docs template](https://github.com/code-forge-io/docs)). The content +is under [`docs/content/`](docs/content/). To run it locally: + +```bash +cd docs +pnpm install +pnpm run dev +``` + ## License deploykit is **source-available** under the [Business Source License 1.1](LICENSE) (`BUSL-1.1`). diff --git a/biome.json b/biome.json index 9c75726..a4fe85d 100644 --- a/biome.json +++ b/biome.json @@ -6,7 +6,7 @@ "useIgnoreFile": true }, "files": { - "includes": ["**", "!!**/dist", "!!**/.claude"] + "includes": ["**", "!!**/dist", "!!**/.claude", "!!docs"] }, "formatter": { "enabled": true, diff --git a/docs/.dockerignore b/docs/.dockerignore new file mode 100644 index 0000000..afd6a06 --- /dev/null +++ b/docs/.dockerignore @@ -0,0 +1,87 @@ +node_modules +public/build +build +dist +out +coverage +.history +.react-router + +# Other Coverage tools +*.lcov + +# macOS +.DS_* + +# Cache Directories and files +.cache +.yarn* +.env* +!.env.example +.swp* +.turbo +.npm +.stylelintcache +*.tsbuildinfo +.node_repl_history + +# Lock files from other package managers +package-lock.json +yarn.lock + +# General tempory files and directories +t?mp +.t?mp +*.t?mp + +# Docusaurus cache and generated files +.docusaurus + +# Output of 'npm pack' +*.tgz +*.tar +*.tar.gz +*.tar.bz2 +*.tbz +*.zip + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +vite.config.ts.* + +# Playwright various test reports +test-results +playwright-report +blob-report + + +# Editors +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# Dont commit sqlite database files +*.db +*.sqlite +*.sqlite3 +*.db-journal + + +# Content collections output files +.content-collections + diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 0000000..886f5db --- /dev/null +++ b/docs/.env.example @@ -0,0 +1,4 @@ +GITHUB_OWNER="abrulic" # Your username or organization name (Optional. For edit/report an issue for the documentation page) +GITHUB_REPO="deploykit" # Repository name (Optional. For edit/report an issue for the documentation page) +APP_ROOT_PATH="/path/to/your/app" # Optional. Default is `process.cwd()` +GITHUB_REPO_URL="https://github.com/abrulic/deploykit" # Optional. If you want to have GitHub icon link in the header or footer diff --git a/docs/.github/PULL_REQUEST_TEMPLATE.md b/docs/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ef94499 --- /dev/null +++ b/docs/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ +Fixes # + +# Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. +List any dependencies that are required for this change. + +## Type of change + +Please mark relevant options with an `x` in the brackets. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update +- [ ] Algorithm update - updates algorithm documentation/questions/answers etc. +- [ ] Other (please describe): + +# How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also +list any relevant details for your test configuration + +- [ ] Integration tests +- [ ] Unit tests +- [ ] Manual tests +- [ ] No tests required + +# Reviewer checklist + +Mark everything that needs to be checked before merging the PR. + +- [ ] Check if the UI is working as expected and is satisfactory +- [ ] Check if the code is well documented +- [ ] Check if the behavior is what is expected +- [ ] Check if the code is well tested +- [ ] Check if the code is readable and well formatted +- [ ] Additional checks (document below if any) + +# Screenshots (if appropriate): + +# Questions (if appropriate): diff --git a/docs/.github/workflows/ci.yml b/docs/.github/workflows/ci.yml new file mode 100644 index 0000000..a53e919 --- /dev/null +++ b/docs/.github/workflows/ci.yml @@ -0,0 +1,127 @@ +name: 🚀 Validation Pipeline +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +on: + pull_request: + branches: [main] + +permissions: + actions: write + contents: read + # Required to put a comment into the pull-request + pull-requests: write +jobs: + lint: + name: ⬣ Biome lint + runs-on: ubuntu-latest + steps: + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + - name: Setup Biome + uses: biomejs/setup-biome@v2 + - name: Run Biome + run: biome ci . + + validate: + name: 🔎 Validate + runs-on: ubuntu-latest + steps: + - name: 🛑 Cancel Previous Runs + uses: styfle/cancel-workflow-action@0.12.1 + - name: ⬇️ Checkout repo + uses: actions/checkout@v4 + - name: ⎔ Setup node + uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + - name: Install pnpm + uses: pnpm/action-setup@v4 + - name: Install dependencies + run: pnpm install + - run: pnpm install --prefer-offline --frozen-lockfile + - run: pnpm exec playwright install chromium --with-deps + - name: 🔎 Test + run: pnpm run test + - name: ✂️ Check unused code + run: pnpm run check:unused + + build-docs: + name: ⬆️ Build Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.head_ref }} + fetch-depth: 0 + + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + cache: pnpm + + - name: Install deps + run: pnpm install --prefer-offline --frozen-lockfile + + - name: Generate docs + env: + APP_ENV: production + run: pnpm run generate:docs + + - name: Pack generated docs (tarball) + run: | + tar -czf docs-generated.tgz generated-docs + ls -lh docs-generated.tgz + + - name: Upload generated docs (tgz) + uses: actions/upload-artifact@v4 + with: + name: docs-generated-tgz + path: docs-generated.tgz + if-no-files-found: error + + - name: Upload versions file + uses: actions/upload-artifact@v4 + with: + name: docs-versions + path: app/utils/versions.ts + if-no-files-found: error + + deploy-docs-pr-preview: + name: 🚀 Deploy Docs + needs: [build-docs] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download generated docs (tgz) + uses: actions/download-artifact@v4 + with: + name: docs-generated-tgz + path: . + + - name: Unpack generated docs + run: | + tar -xzf docs-generated.tgz + ls -laR generated-docs | sed -n '1,200p' + - name: Download versions file + uses: actions/download-artifact@v4 + with: + name: docs-versions + path: app/utils/ + + - uses: forge-42/fly-deploy@v1.0.0-rc.2 + id: deploy + env: + FLY_ORG: ${{ vars.FLY_ORG }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_REGION: ${{ vars.FLY_REGION }} + with: + app_name: ${{ github.event.repository.name }}-${{ github.event.number }} + use_isolated_workspace: true + env_vars: | + APP_ENV=production + GITHUB_OWNER=${{ github.repository_owner }} + GITHUB_REPO=${{ github.event.repository.name }} + GITHUB_REPO_URL=https://github.com/${{ github.repository }} diff --git a/docs/.github/workflows/pr-close.yml b/docs/.github/workflows/pr-close.yml new file mode 100644 index 0000000..d241fda --- /dev/null +++ b/docs/.github/workflows/pr-close.yml @@ -0,0 +1,25 @@ +name: 🧹 PR Close + +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +on: + pull_request: + branches: [main] + types: closed + +jobs: + + destroy-pr-preview: + name: 🧹 Destroy PR Preview + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: forge-42/fly-destroy@v1.0.0-rc.2 + id: destroy + env: + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_ORG: ${{ vars.FLY_ORG }} + with: + app_name: ${{github.event.repository.name}}-${{ github.event.number }} diff --git a/docs/.github/workflows/publish-documentation.yml b/docs/.github/workflows/publish-documentation.yml new file mode 100644 index 0000000..9bb67bd --- /dev/null +++ b/docs/.github/workflows/publish-documentation.yml @@ -0,0 +1,93 @@ +name: 📚🚀 Build documentation on release + +on: + release: + types: [published] + workflow_dispatch: {} + +concurrency: + group: docs-build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-docs: + name: Build Docs + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version-file: "package.json" + cache: pnpm + + - name: Install deps + run: pnpm install --prefer-offline --frozen-lockfile + + - name: Generate docs + env: + APP_ENV: production + run: pnpm run generate:docs + + - name: Pack generated docs (tarball) + run: | + tar -czf docs-generated.tgz generated-docs + ls -lh docs-generated.tgz + - name: Upload generated docs (tgz) + uses: actions/upload-artifact@v4 + with: + name: docs-generated-tgz + path: docs-generated.tgz + if-no-files-found: error + + - name: Upload versions file + uses: actions/upload-artifact@v4 + with: + name: docs-versions + path: app/utils/versions.ts + if-no-files-found: error + + deploy-docs-on-release: + needs: [build-docs] + name: Deploy Docs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download generated docs (tgz) + uses: actions/download-artifact@v4 + with: + name: docs-generated-tgz + path: . + + - name: Unpack generated docs into docs/ + run: | + tar -xzf docs-generated.tgz + ls -laR generated-docs | sed -n '1,200p' + - name: Download versions file + uses: actions/download-artifact@v4 + with: + name: docs-versions + path: docs/app/utils + + - uses: forge-42/fly-deploy@v1.0.0-rc.2 + id: deploy + env: + FLY_ORG: ${{ vars.FLY_ORG }} + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} + FLY_REGION: ${{ vars.FLY_REGION }} + with: + app_name: ${{ vars.FLY_APP_NAME || format('{0}-{1}', github.event.repository.name, github.ref_name) }} + use_isolated_workspace: true + env_vars: | + APP_ENV=production + GITHUB_OWNER=${{ github.repository_owner }} + GITHUB_REPO=${{ github.event.repository.name }} + GITHUB_REPO_URL=https://github.com/${{ github.repository }} diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..df59db5 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,89 @@ +node_modules +public/build +build +dist +out +coverage +.history +.react-router + +# Other Coverage tools +*.lcov + +# macOS +.DS_* + +# Cache Directories and files +.cache +.yarn* +.env* +!.env.example +.swp* +.turbo +.npm +.stylelintcache +*.tsbuildinfo +.node_repl_history + +# Lock files from other package managers +package-lock.json +yarn.lock + +# General tempory files and directories +t?mp +.t?mp +*.t?mp + +# Docusaurus cache and generated files +.docusaurus + +# Output of 'npm pack' +*.tgz +*.tar +*.tar.gz +*.tar.bz2 +*.tbz +*.zip + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* +vite.config.ts.* + +# Playwright various test reports +test-results +playwright-report +blob-report + + +# Editors +.idea/workspace.xml +.idea/usage.statistics.xml +.idea/shelf + +# Dont commit sqlite database files +*.db +*.sqlite +*.sqlite3 +*.db-journal + + +# Content collections output files +.content-collections + +# Output base directory of the documentation +generated-docs/ diff --git a/docs/.npmrc b/docs/.npmrc new file mode 100644 index 0000000..9e299db --- /dev/null +++ b/docs/.npmrc @@ -0,0 +1,6 @@ +enable-pre-post-scripts=true +side-effects-cache=false +save-exact=true +audit=false +fund=false +progress=false \ No newline at end of file diff --git a/docs/.vscode/extensions.json b/docs/.vscode/extensions.json new file mode 100644 index 0000000..06e7434 --- /dev/null +++ b/docs/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["codeforge.remix-forge", "biomejs.biome"] +} diff --git a/docs/.vscode/settings.json b/docs/.vscode/settings.json new file mode 100644 index 0000000..85f2f2c --- /dev/null +++ b/docs/.vscode/settings.json @@ -0,0 +1,47 @@ +{ + "editor.formatOnSave": true, + "editor.formatOnType": false, + "editor.renderWhitespace": "all", + "editor.rulers": [120, 160], + "editor.codeActionsOnSave": { + "source.fixAll": "always", + "source.organizeImports": "never", + "source.organizeImports.biome": "always", + "quickfix.biome": "always" + }, + "eslint.enable": false, + "prettier.enable": false, + "editor.insertSpaces": false, + "editor.detectIndentation": false, + "editor.tabSize": 2, + "editor.trimAutoWhitespace": true, + "workbench.colorCustomizations": { + "editorWhitespace.foreground": "#333" + }, + "files.trimTrailingWhitespace": true, + "files.trimTrailingWhitespaceInRegexAndStrings": true, + "files.trimFinalNewlines": true, + "[yaml]": { + "editor.defaultFormatter": "redhat.vscode-yaml" + }, + "biome.enabled": true, + "editor.defaultFormatter": "biomejs.biome", + "[javascript][typescript][typescriptreact][javascriptreact][json][jsonc][vue][astro][svelte][css][graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "explorer.fileNesting.patterns": { + "*.ts": "${basename}.*.${extname}", + ".env": ".env.*", + "*.tsx": "${basename}.*.${extname},${basename}.*.ts", + "package.json": "*.json, *.yml, *.config.js, *.config.ts, *.yaml, *.workspace.ts", + "readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", + "Readme*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", + "README*": "AUTHORS, Authors, BACKERS*, Backers*, CHANGELOG*, CITATION*, CODEOWNERS, CODE_OF_CONDUCT*, CONTRIBUTING*, CONTRIBUTORS, COPYING*, CREDITS, Changelog*, Citation*, Code_Of_Conduct*, Codeowners, Contributing*, Contributors, Copying*, Credits, GOVERNANCE.MD, Governance.md, HISTORY.MD, History.md, LICENSE*, License*, MAINTAINERS, Maintainers, README-*, README_*, RELEASE_NOTES*, ROADMAP.MD, Readme-*, Readme_*, Release_Notes*, Roadmap.md, SECURITY.MD, SPONSORS*, Security.md, Sponsors*, authors, backers*, changelog*, citation*, code_of_conduct*, codeowners, contributing*, contributors, copying*, credits, governance.md, history.md, license*, maintainers, readme-*, readme_*, release_notes*, roadmap.md, security.md, sponsors*", + "Dockerfile": "*.dockerfile, .devcontainer.*, .dockerignore, captain-definition, compose.*, docker-compose.*, dockerfile*" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "editor.formatOnPaste": true +} diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9dc8168 --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/docs/Dockerfile b/docs/Dockerfile new file mode 100644 index 0000000..ba5788d --- /dev/null +++ b/docs/Dockerfile @@ -0,0 +1,38 @@ +# syntax = docker/dockerfile:1.4 + +ARG NODE_VERSION=22 +FROM node:${NODE_VERSION}-slim AS base + +LABEL fly_launch_runtime="Node.js" +WORKDIR /app +ENV NODE_ENV=production + +ARG PNPM_VERSION=10.18.0 +RUN npm install -g pnpm@$PNPM_VERSION + +# --- Build stage --- +# We consume the docs generated by the CI +FROM base AS build + +RUN apt-get update -qq && \ + apt-get install --no-install-recommends -y build-essential node-gyp pkg-config python-is-python3 && \ + rm -rf /var/lib/apt/lists/* + +COPY .npmrc package.json pnpm-lock.yaml ./ +RUN pnpm install --prod=false --frozen-lockfile + + +COPY . . + +RUN pnpm run build + +# Prune dev deps +RUN pnpm prune --prod + +# --- Runtime stage --- +FROM base + +COPY --from=build /app /app + +EXPOSE 3000 +CMD ["pnpm","run","start"] \ No newline at end of file diff --git a/docs/LICENSE b/docs/LICENSE new file mode 100644 index 0000000..5a34b88 --- /dev/null +++ b/docs/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Forge 42 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..7b90106 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,66 @@ +# deploykit docs + +The documentation site for [deploykit](../README.md), built with the +[code-forge docs template](https://github.com/code-forge-io/docs) +(React Router v7 + [content-collections](https://github.com/sdorra/content-collections)). + +This is a **self-contained subproject**: it has its own `package.json`, +`node_modules`, and tooling, and is not part of the root deploykit package build. + +## Develop + +```bash +cd docs +pnpm install +pnpm run dev +``` + +The dev server serves live content from the `content/` folder with hot reload. + +## Content + +All documentation lives in [`content/`](./content) as `.md` / `.mdx` files. +The sidebar is generated automatically from the folder structure: + +``` +content/ +├── _index.mdx # landing page +├── 01-introduction.mdx +├── 02-getting-started/ +│ ├── index.md # section title +│ ├── 01-installation.mdx +│ ├── 02-quick-start.mdx +│ └── 03-what-it-generates.mdx +├── 03-core-concepts/ +├── 04-commands/ +├── 05-guides/ +└── 06-reference/ +``` + +- Numeric prefixes (`01-`, `02-`) control ordering; they're stripped from the URL. +- Every section folder needs an `index.md` whose `title` becomes the sidebar label. +- Each `.mdx` page needs frontmatter: `title`, `summary`, `description`. + +## Build + +```bash +pnpm run build # production build +pnpm run start # serve the production build +pnpm run typecheck # tsc +pnpm run test # vitest +``` + +## Configuration + +- `.env.example` → copy to `.env`. `GITHUB_OWNER` / `GITHUB_REPO` / `GITHUB_REPO_URL` + drive the "edit this page" / "report an issue" links and the header GitHub icon. +- Branding lives in `app/routes/index.tsx` (landing page), `app/utils/seo.ts` + (site name / OG image), and `app/routes/documentation-layout.tsx` (header logo). + +## Deployment + +The template ships a `Dockerfile`, a `fly.toml` (app `deploykit-docs`), and +example GitHub Actions workflows under `.github/workflows/`. These are **not** +wired into the root repo's CI. To publish the docs, review those workflows, +set the `FLY_API_TOKEN` secret, and adapt paths as needed — or host the +`build/` output anywhere that runs a Node server. diff --git a/docs/app/components/backdrop.tsx b/docs/app/components/backdrop.tsx new file mode 100644 index 0000000..2e49cf4 --- /dev/null +++ b/docs/app/components/backdrop.tsx @@ -0,0 +1,16 @@ +import { cn } from "~/utils/css" + +export const Backdrop = ({ onClose, className }: { onClose: () => void; className?: string }) => ( + // biome-ignore lint/a11y/useKeyWithClickEvents: We don't need keyboard events for backdrop +
{ + if (e.target === e.currentTarget) { + onClose() + } + }} + /> +) diff --git a/docs/app/components/code-block/code-block-diff.ts b/docs/app/components/code-block/code-block-diff.ts new file mode 100644 index 0000000..0812efb --- /dev/null +++ b/docs/app/components/code-block/code-block-diff.ts @@ -0,0 +1,45 @@ +/** + * This module provides utilities for processing and styling lines of a text diff. + */ +const DIFF_STYLES = { + added: { + backgroundColor: "var(--color-diff-added-bg)", + borderLeft: "2px solid", + borderLeftColor: "var(--color-diff-added-border)", + indicator: "+", + }, + removed: { + backgroundColor: "var(--color-diff-removed-bg)", + borderLeft: "2px solid", + borderLeftColor: "var(--color-diff-removed-border)", + indicator: "-", + }, + normal: { + backgroundColor: "transparent", + borderLeft: "none", + borderLeftColor: "transparent", + indicator: "", + }, +} as const + +type DiffType = keyof typeof DIFF_STYLES + +const DIFF_PATTERNS = { + "+ ": "added", + "- ": "removed", +} as const + +type DiffPatternPrefix = keyof typeof DIFF_PATTERNS + +const isDiffPatternPrefix = (prefix: string): prefix is DiffPatternPrefix => { + return prefix in DIFF_PATTERNS +} + +export const getDiffType = (line: string): DiffType => { + const prefix = line.trimStart().slice(0, 2) + return isDiffPatternPrefix(prefix) ? DIFF_PATTERNS[prefix] : "normal" +} + +export const cleanDiffLine = (line: string) => line.replace(/^(\s*)[+-] /, "$1") + +export const getDiffStyles = (diffType: DiffType) => DIFF_STYLES[diffType] diff --git a/docs/app/components/code-block/code-block-elements.tsx b/docs/app/components/code-block/code-block-elements.tsx new file mode 100644 index 0000000..3efe019 --- /dev/null +++ b/docs/app/components/code-block/code-block-elements.tsx @@ -0,0 +1,66 @@ +import type { ComponentPropsWithoutRef } from "react" +import { cn } from "~/utils/css" +import { createLineData } from "./code-block-parser" +import { getTokenColor, isTokenType, type tokenize } from "./code-block-syntax-highlighter" + +const TokenElement = ({ token }: { token: ReturnType[0] }) => { + const { type, value } = token + const color = isTokenType(type) ? getTokenColor(type) : "" + + return {value} +} + +const DiffIndicator = ({ indicator }: { indicator: string }) => ( + + {indicator} + +) + +const LineElement = ({ line }: { line: string }) => { + const { tokens, styles, isNormalDiff } = createLineData(line) + + return ( +
+
+ {!isNormalDiff && } + + {tokens.map((token, index) => ( + + ))} + +
+
+ ) +} + +const CodeElement = ({ lines }: { lines: string[] }) => ( + + {lines.map((line, index) => ( + + ))} + +) + +interface PreElementProps extends Omit, "children"> { + lines: string[] + className?: string +} + +export const PreElement = ({ lines, className = "", ...props }: PreElementProps) => ( +
+		
+	
+) diff --git a/docs/app/components/code-block/code-block-parser.ts b/docs/app/components/code-block/code-block-parser.ts new file mode 100644 index 0000000..ec64f3d --- /dev/null +++ b/docs/app/components/code-block/code-block-parser.ts @@ -0,0 +1,53 @@ +import { cleanDiffLine, getDiffStyles, getDiffType } from "./code-block-diff" +import { tokenize } from "./code-block-syntax-highlighter" + +interface CodeBlockChild { + props?: { + children?: string + } +} + +export const extractCodeContent = (children: string | CodeBlockChild) => { + const code = typeof children === "string" ? children : (children?.props?.children ?? "") + return { code } +} + +export const processLines = (content: string) => { + const lines = content.split("\n") + return filterEmptyLines(lines) +} + +const filterEmptyLines = (lines: string[]) => { + return lines.filter((line, index, array) => { + const isLastLine = index === array.length - 1 + const isEmpty = line.trim() === "" + return !(isEmpty && isLastLine) + }) +} + +export const createLineData = (line: string) => { + const diffType = getDiffType(line) + const cleanLine = cleanDiffLine(line) + const tokens = tokenize(cleanLine) + const styles = getDiffStyles(diffType) + const isNormalDiff = diffType === "normal" + + return { + diffType, + cleanLine, + tokens, + styles, + isNormalDiff, + } +} + +export const processCopyContent = (content: string): { code: string } => { + // removes diff markers from content + const code = content + .split("\n") + .filter((line) => !line.trimStart().startsWith("- ")) + .map((line) => line.replace(/^(\s*)\+ /, "$1")) + .join("\n") + + return { code } +} diff --git a/docs/app/components/code-block/code-block-syntax-highlighter.ts b/docs/app/components/code-block/code-block-syntax-highlighter.ts new file mode 100644 index 0000000..11980b5 --- /dev/null +++ b/docs/app/components/code-block/code-block-syntax-highlighter.ts @@ -0,0 +1,157 @@ +/** + * Tokenization utility for syntax highlighting code snippets. + * This utils will produce syntax-highlighted JSX output using theme colors. + */ + +type TokenType = "keyword" | "string" | "number" | "comment" | "operator" | "punctuation" | "function" | "text" + +const MASTER_REGEX = new RegExp( + [ + // whitespace + "\\s+", + // single-line comment + "\\/\\/[^\\n\\r]*(?=\\n|$)", + // multi-line comment + "\\/\\*[\\s\\S]*?\\*\\/", + // hash comment at start of line + "^\\s*#.*$", + // backtick inline code + "\\`(?:[^`\\\\]|\\\\.)*\\`", + // strings + "(['\"])(?:(?!\\1)[^\\\\]|\\\\.)*\\1", + // numbers + "\\d+\\.?\\d*", + // identifiers + "[a-zA-Z_$][a-zA-Z0-9_$]*", + // arrow function + "=>", + // operators & punctuation + "===|!==|<=|>=|==|!=|&&|\\|\\||\\+\\+|--|[+\\-*%=<>!?:(){}\\[\\];,.]|\\/(?![/*])|[+\\-*/%]=", + ].join("|"), + "gm" +) + +const KEYWORDS = [ + "import", + "export", + "default", + "from", + "const", + "let", + "var", + "function", + "return", + "if", + "else", + "for", + "while", + "do", + "switch", + "case", + "break", + "continue", + "try", + "catch", + "finally", + "throw", + "new", + "class", + "extends", + "interface", + "type", + "public", + "private", + "protected", + "static", + "async", + "await", + "true", + "false", + "null", + "undefined", + "typeof", + "instanceof", +] + +const OPERATORS = [ + "+", + "-", + "*", + "/", + "=", + "==", + "===", + "!=", + "!==", + "<", + ">", + "<=", + ">=", + "&&", + "||", + "!", + "?", + ":", + "++", + "--", + "+=", + "-=", + "*=", + "/=", + "=>", +] + +const isKeyword = (value: string) => KEYWORDS.includes(value) +const isOperator = (value: string) => OPERATORS.includes(value) +const isFunction = (value: string) => /^[A-Z]/.test(value) +const isWhitespace = (value: string) => /^\s/.test(value) +const isComment = (v: string) => v.startsWith("//") || v.startsWith("/*") || /^\s*#/.test(v) +const isString = (value: string) => /^['"`]/.test(value) +const isNumber = (value: string) => /^\d/.test(value) +const isIdentifier = (value: string) => /^[a-zA-Z_$]/.test(value) + +const classifyIdentifier = (value: string) => { + return isKeyword(value) ? "keyword" : isFunction(value) ? "function" : "text" +} + +const classifyToken = (value: string) => { + switch (true) { + case isWhitespace(value): + return "text" + case isComment(value): + return "comment" + case isString(value): + return "string" + case isNumber(value): + return "number" + case isIdentifier(value): + return classifyIdentifier(value) + case isOperator(value): + return "operator" + default: + return "punctuation" + } +} + +export const tokenize = (code: string) => + Array.from(code.matchAll(MASTER_REGEX), (match) => ({ + type: classifyToken(match[0]), + value: match[0], + })) + +const TOKEN_COLORS = { + keyword: "var(--color-code-keyword)", + string: "var(--color-code-string)", + number: "var(--color-code-number)", + comment: "var(--color-code-comment)", + operator: "var(--color-code-operator)", + punctuation: "var(--color-code-punctuation)", + function: "var(--color-code-function)", + text: "var(--color-code-block-text)", +} as const + +export const getTokenColor = (type: TokenType) => TOKEN_COLORS[type] + +export function isTokenType(value: unknown): value is TokenType { + return typeof value === "string" && value in TOKEN_COLORS +} diff --git a/docs/app/components/code-block/code-block.tsx b/docs/app/components/code-block/code-block.tsx new file mode 100644 index 0000000..1ecc8c8 --- /dev/null +++ b/docs/app/components/code-block/code-block.tsx @@ -0,0 +1,20 @@ +import type { ComponentPropsWithoutRef } from "react" +import { PreElement } from "./code-block-elements" +import { extractCodeContent, processLines } from "./code-block-parser" +import { CopyButton } from "./copy-button" + +interface CodeBlockProps extends Omit, "children"> { + children: string +} + +export const CodeBlock = ({ children, className = "", ...props }: CodeBlockProps) => { + const { code } = extractCodeContent(children) + const lines = processLines(code) + + return ( +
+ + +
+ ) +} diff --git a/docs/app/components/code-block/copy-button.tsx b/docs/app/components/code-block/copy-button.tsx new file mode 100644 index 0000000..79e9c19 --- /dev/null +++ b/docs/app/components/code-block/copy-button.tsx @@ -0,0 +1,38 @@ +import { useState } from "react" +import { useTranslation } from "react-i18next" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import { processCopyContent } from "./code-block-parser" + +export const CopyButton = ({ lines }: { lines: string[] }) => { + const [copyState, setCopyState] = useState<"copy" | "copied">("copy") + const disabled = copyState === "copied" + const { t } = useTranslation() + + const handleCopy = async () => { + const reconstructedContent = lines.join("\n") + const { code } = processCopyContent(reconstructedContent) + + await navigator.clipboard.writeText(code) + setCopyState("copied") + setTimeout(() => setCopyState("copy"), 2000) + } + + return ( + + ) +} diff --git a/docs/app/components/code-block/tests/code-block-diff.test.ts b/docs/app/components/code-block/tests/code-block-diff.test.ts new file mode 100644 index 0000000..0386e80 --- /dev/null +++ b/docs/app/components/code-block/tests/code-block-diff.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" +import { cleanDiffLine, getDiffStyles, getDiffType } from "../code-block-diff" + +describe("getDiffType test suite", () => { + it("should return 'added' for lines starting with '+ '", () => { + expect(getDiffType("+ something")).toBe("added") + }) + + it("should return 'removed' for lines starting with '- '", () => { + expect(getDiffType("- something")).toBe("removed") + }) + + it("should return 'normal' for lines without a diff prefix", () => { + expect(getDiffType(" unchanged line")).toBe("normal") + }) + + it("should handle leading whitespace before '+ '", () => { + expect(getDiffType(" + spaced")).toBe("added") + }) + + it("should handle leading whitespace before '- '", () => { + expect(getDiffType(" - spaced")).toBe("removed") + }) + + it("should return 'normal' for '+' without space after", () => { + expect(getDiffType("+no-space")).toBe("normal") + }) + + it("should return 'normal' for '-' without space after", () => { + expect(getDiffType("-no-space")).toBe("normal") + }) +}) + +describe("cleanDiffLine test suite", () => { + it("should remove '+ ' from start while preserving indentation", () => { + expect(cleanDiffLine("+ added")).toBe("added") + expect(cleanDiffLine(" + added")).toBe(" added") + }) + + it("should remove '- ' from start while preserving indentation", () => { + expect(cleanDiffLine("- removed")).toBe("removed") + expect(cleanDiffLine(" - removed")).toBe(" removed") + }) + + it("should not change lines without diff prefix", () => { + expect(cleanDiffLine("unchanged")).toBe("unchanged") + expect(cleanDiffLine(" unchanged")).toBe(" unchanged") + }) +}) + +describe("getDiffStyles test suite", () => { + it.each(["added", "removed", "normal"] as const)("should have backgroundColor and indicator for '%s'", (diffType) => { + const styles = getDiffStyles(diffType) + expect(styles).toHaveProperty("backgroundColor") + expect(styles).toHaveProperty("indicator") + }) + + it("should return correct backgroundColor and indicator for 'added'", () => { + const styles = getDiffStyles("added") + expect(styles.backgroundColor).toBe("var(--color-diff-added-bg)") + expect(styles.indicator).toBe("+") + }) + + it("should return correct backgroundColor and indicator for 'removed'", () => { + const styles = getDiffStyles("removed") + expect(styles.backgroundColor).toBe("var(--color-diff-removed-bg)") + expect(styles.indicator).toBe("-") + }) + + it("should return correct backgroundColor and indicator for 'normal'", () => { + const styles = getDiffStyles("normal") + expect(styles.backgroundColor).toBe("transparent") + expect(styles.indicator).toBe("") + }) +}) diff --git a/docs/app/components/code-block/tests/code-block-parser.test.ts b/docs/app/components/code-block/tests/code-block-parser.test.ts new file mode 100644 index 0000000..a32db06 --- /dev/null +++ b/docs/app/components/code-block/tests/code-block-parser.test.ts @@ -0,0 +1,67 @@ +import { extractCodeContent, processCopyContent, processLines } from "../code-block-parser" + +describe("extractCodeContent test suite", () => { + it("should return code when children is a string", () => { + expect(extractCodeContent("console.log('hello')")).toEqual({ + code: "console.log('hello')", + }) + }) + + it("should return code from props.children when children is an object", () => { + expect(extractCodeContent({ props: { children: "const x = 1" } })).toEqual({ + code: "const x = 1", + }) + }) + + it("should return empty string if children has no props or children", () => { + // biome-ignore lint/suspicious/noExplicitAny: in tests we may use any type + expect(extractCodeContent({} as any)).toEqual({ code: "" }) + }) +}) + +describe("processLines test suite", () => { + it("should split lines by newline", () => { + expect(processLines("line1\nline2")).toEqual(["line1", "line2"]) + }) + + it("should remove trailing empty line", () => { + expect(processLines("line1\n")).toEqual(["line1"]) + }) + + it("should keep empty lines in the middle", () => { + expect(processLines("a\n\nb")).toEqual(["a", "", "b"]) + }) + + it("should return empty array for empty string", () => { + expect(processLines("")).toEqual([]) + }) +}) + +describe("processCopyContent test suite", () => { + it("should remove removed lines (starting with '- ')", () => { + const result = processCopyContent("- removed\nunchanged") + expect(result.code).toBe("unchanged") + }) + + it("should strip '+ ' from added lines but keep indentation", () => { + const result = processCopyContent("+ added\n unchanged") + expect(result.code).toBe("added\n unchanged") + }) + + it("should handle mixed added, removed, and unchanged lines", () => { + const content = ` +- removed ++ added + unchanged +` + const result = processCopyContent(content) + expect(result.code).toContain("added") + expect(result.code).toContain("unchanged") + expect(result.code).not.toContain("removed") + }) + + it("should return empty string if all lines are removed", () => { + const result = processCopyContent("- a\n- b") + expect(result.code).toBe("") + }) +}) diff --git a/docs/app/components/code-block/tests/code-block-syntax-highlighter.test.ts b/docs/app/components/code-block/tests/code-block-syntax-highlighter.test.ts new file mode 100644 index 0000000..d2cf1f5 --- /dev/null +++ b/docs/app/components/code-block/tests/code-block-syntax-highlighter.test.ts @@ -0,0 +1,110 @@ +import { getTokenColor, isTokenType, tokenize } from "../code-block-syntax-highlighter" + +describe("tokenize test suite", () => { + it("should tokenize keywords", () => { + const tokens = tokenize("const let var function return if else") + expect(tokens.map((t) => t.type)).toContain("keyword") + expect(tokens.some((t) => t.value === "const")).toBe(true) + }) + + it("should tokenize strings (single and double quotes)", () => { + const tokens = tokenize(`'hello' "world"`) + expect(tokens.filter((t) => t.type === "string")).toHaveLength(2) + }) + + it("should tokenize numbers (integers and floats)", () => { + const tokens = tokenize("42 3.14") + expect(tokens.filter((t) => t.type === "number")).toHaveLength(2) + }) + + it("should tokenize single-line comments", () => { + const tokens = tokenize("// comment here") + expect(tokens[0]).toEqual({ type: "comment", value: "// comment here" }) + }) + + it("should tokenize multi-line comments", () => { + const tokens = tokenize("/* multi\nline\ncomment */") + expect(tokens[0].type).toBe("comment") + }) + + it("should tokenize operators", () => { + const tokens = tokenize("a + b - c * d / e == f && g || h") + expect(tokens.filter((t) => t.type === "operator").length).toBeGreaterThan(0) + }) + + it("should tokenize punctuation", () => { + const tokens = tokenize("{ } ( ) [ ] ; , .") + expect(tokens.filter((t) => t.type === "punctuation").length).toBeGreaterThan(0) + }) + + it("should classify whitespace as text", () => { + const tokens = tokenize(" \n\t") + expect(tokens.every((t) => t.type === "text")).toBe(true) + }) + + it("should classify lowercase identifiers as text when not keywords", () => { + const tokens = tokenize("myVariable anotherThing") + const nonWhitespaceTextTokens = tokens.filter((t) => t.type === "text" && t.value.trim() !== "") + expect(nonWhitespaceTextTokens.length).toBe(2) + }) + it("should handle empty input", () => { + expect(tokenize("")).toEqual([]) + }) + + it("should handle mixed code sample", () => { + const code = ` + // comment + const x = 42; + function Test() { + return "hello"; + } + ` + const tokens = tokenize(code) + expect(tokens.some((t) => t.type === "keyword")).toBe(true) + expect(tokens.some((t) => t.type === "function")).toBe(true) + expect(tokens.some((t) => t.type === "string")).toBe(true) + expect(tokens.some((t) => t.type === "comment")).toBe(true) + expect(tokens.some((t) => t.type === "number")).toBe(true) + }) +}) + +describe("getTokenColor", () => { + it("should return a valid CSS variable for each TokenType", () => { + const tokenTypes = [ + "keyword", + "string", + "number", + "comment", + "operator", + "punctuation", + "function", + "text", + ] as const + + for (const type of tokenTypes) { + const color = getTokenColor(type) + expect(color).toMatch(/^var\(--color-code-/) + } + }) +}) + +describe("isTokenType", () => { + it("should return true for valid token types", () => { + expect(isTokenType("keyword")).toBe(true) + expect(isTokenType("string")).toBe(true) + expect(isTokenType("function")).toBe(true) + }) + + it("should return false for invalid strings", () => { + expect(isTokenType("not-a-type")).toBe(false) + expect(isTokenType("")).toBe(false) + expect(isTokenType("KEYWORD")).toBe(false) + }) + + it("should return false for non-string values", () => { + expect(isTokenType(undefined)).toBe(false) + expect(isTokenType(null)).toBe(false) + expect(isTokenType(42)).toBe(false) + expect(isTokenType({})).toBe(false) + }) +}) diff --git a/docs/app/components/command-k/components/command-k.tsx b/docs/app/components/command-k/components/command-k.tsx new file mode 100644 index 0000000..57b3986 --- /dev/null +++ b/docs/app/components/command-k/components/command-k.tsx @@ -0,0 +1,135 @@ +import { useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { useNavigate } from "react-router" +import { Modal } from "~/components/modal" +import type { Version } from "~/utils/version-resolvers" +import { useKeyboardNavigation } from "../hooks/use-keyboard-navigation" +import { useModalState } from "../hooks/use-modal-state" +import { useSearch } from "../hooks/use-search" +import { useSearchHistory } from "../hooks/use-search-history" +import type { HistoryItem, MatchType, SearchResult } from "../search-types" +import { EmptyState } from "./empty-state" +import { ResultsFooter } from "./results-footer" +import { SearchHistory } from "./search-history" +import { SearchInput } from "./search-input" +import { SearchResultRow } from "./search-result" +import { TriggerButton } from "./trigger-button" + +interface CommandPaletteProps { + placeholder?: string + version: Version +} + +export const CommandK = ({ placeholder, version }: CommandPaletteProps) => { + const { t } = useTranslation() + const navigate = useNavigate() + const inputRef = useRef(null) + const [query, setQuery] = useState("") + const { isOpen, openModal, closeModal } = useModalState() + const { history, addToHistory, clearHistory, removeFromHistory } = useSearchHistory(version) + const { results, search } = useSearch({ version }) + + const hasQuery = !!query.trim() + const hasResults = !!results.length + const hasHistory = !!history.length + const searchPlaceholder = placeholder ?? t("placeholders.search_documentation") + + const handleClose = () => { + closeModal() + setQuery("") + search("") + } + + const navigateToPage = (id: string) => { + const path = [version, id] + .filter(Boolean) + .map((s) => s.replace(/^\/+|\/+$/g, "")) + .join("/") + + navigate(`/${path}`) + } + + const handleResultSelect = (result: SearchResult) => { + if (!isOpen) return + const rowItem = result.item + const matchType: MatchType = result.refIndex === 0 ? "heading" : "paragraph" + const historyItem = { + ...rowItem, + type: matchType, + highlightedText: result.highlightedText, + } + + addToHistory(historyItem) + navigateToPage(rowItem.id) + handleClose() + } + + const handleHistorySelect = (item: HistoryItem) => { + navigateToPage(item.id) + handleClose() + } + + const handleToggle = () => { + isOpen ? handleClose() : openModal() + } + + const { selectedIndex } = useKeyboardNavigation({ + isOpen, + results, + onSelect: handleResultSelect, + onClose: handleClose, + onToggle: handleToggle, + }) + + if (!isOpen) { + return + } + + const renderBody = () => { + if (hasQuery) { + if (!hasResults) return + + return results.map((result, index) => ( + handleResultSelect(result)} + matchType={result.refIndex === 0 ? "heading" : "paragraph"} + /> + )) + } + + if (hasHistory) { + return ( + + ) + } + + return + } + + return ( + inputRef.current} ariaLabel={searchPlaceholder}> + { + setQuery(val) + search(val.trim()) + }} + placeholder={searchPlaceholder} + /> +
+ {renderBody()} +
+ +
+ ) +} diff --git a/docs/app/components/command-k/components/empty-state.tsx b/docs/app/components/command-k/components/empty-state.tsx new file mode 100644 index 0000000..fc79b79 --- /dev/null +++ b/docs/app/components/command-k/components/empty-state.tsx @@ -0,0 +1,29 @@ +import { useTranslation } from "react-i18next" +import { KeyboardHint } from "./keyboard-hint" +import { ResultsFooterNote } from "./results-footer-note" + +export const EmptyState = ({ query }: { query?: string }) => { + const { t } = useTranslation() + if (query) { + return ( +
+

+ {t("text.no_results_for")} "{query}" +

+

{t("text.adjust_search")}

+
+ ) + } + + return ( +
+

{t("text.start_typing_to_search")}

+
+ + + +
+ +
+ ) +} diff --git a/docs/app/components/command-k/components/keyboard-hint.tsx b/docs/app/components/command-k/components/keyboard-hint.tsx new file mode 100644 index 0000000..7233ae5 --- /dev/null +++ b/docs/app/components/command-k/components/keyboard-hint.tsx @@ -0,0 +1,21 @@ +import { Kbd } from "~/ui/kbd" +import { cn } from "~/utils/css" + +interface KeyboardHintProps { + keys: string | string[] + label: string + className?: string +} + +export const KeyboardHint = ({ keys, label, className }: KeyboardHintProps) => { + const keyArray = Array.isArray(keys) ? keys : [keys] + + return ( +
+ {keyArray.map((key) => ( + {key} + ))} + {label} +
+ ) +} diff --git a/docs/app/components/command-k/components/results-footer-note.tsx b/docs/app/components/command-k/components/results-footer-note.tsx new file mode 100644 index 0000000..1c2dd41 --- /dev/null +++ b/docs/app/components/command-k/components/results-footer-note.tsx @@ -0,0 +1,15 @@ +import { useTranslation } from "react-i18next" + +export const ResultsFooterNote = () => { + const { t } = useTranslation() + return ( + + {t("p.search_by")}{" "} + + + Forge 42 + + + + ) +} diff --git a/docs/app/components/command-k/components/results-footer.tsx b/docs/app/components/command-k/components/results-footer.tsx new file mode 100644 index 0000000..4aef83a --- /dev/null +++ b/docs/app/components/command-k/components/results-footer.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" +import { cn } from "~/utils/css" +import { KeyboardHint } from "./keyboard-hint" +import { ResultsFooterNote } from "./results-footer-note" + +export const ResultsFooter = ({ + resultsCount, + query, +}: { + resultsCount: number + query: string +}) => { + const { t } = useTranslation() + if (!query || resultsCount === 0) return null + + return ( +
+
+ {t("text.result", { count: resultsCount })} +
+ + + +
+
+
+ ) +} diff --git a/docs/app/components/command-k/components/search-history.tsx b/docs/app/components/command-k/components/search-history.tsx new file mode 100644 index 0000000..e227513 --- /dev/null +++ b/docs/app/components/command-k/components/search-history.tsx @@ -0,0 +1,122 @@ +import { useTranslation } from "react-i18next" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import type { HistoryItem } from "../search-types" +import { SearchResultRow } from "./search-result" +interface SearchHistoryProps { + history: HistoryItem[] + onSelect: (item: HistoryItem) => void + onRemove: (id: string) => void + onClear: () => void +} + +const SearchHistoryHeader = ({ onClear }: Pick) => { + const { t } = useTranslation() + return ( +
+
+ + {t("text.recent_searches")} +
+ +
+ ) +} + +const ClearHistoryButton = ({ onClear }: Pick) => { + const { t } = useTranslation() + return ( + + ) +} + +const RemoveItemButton = ({ + onRemove, + id, +}: { + onRemove: Pick["onRemove"] + id: string +}) => ( + +) + +const HistoryItemRow = ({ + item, + index, + onSelect, + onRemove, +}: { + item: HistoryItem + index: number + onSelect: Pick["onSelect"] + onRemove: Pick["onRemove"] +}) => ( +
+ onSelect(item)} + matchType={item.type ?? "heading"} + /> + +
+) + +const HistoryItemsList = ({ + history, + onSelect, + onRemove, +}: { + history: HistoryItem[] + onSelect: Pick["onSelect"] + onRemove: Pick["onRemove"] +}) => ( +
+ {history.map((item, index) => ( + + ))} +
+) + +export const SearchHistory = ({ history, onSelect, onRemove, onClear }: SearchHistoryProps) => { + if (history.length === 0) return null + return ( +
+ + +
+ ) +} diff --git a/docs/app/components/command-k/components/search-input.tsx b/docs/app/components/command-k/components/search-input.tsx new file mode 100644 index 0000000..5dcaaa6 --- /dev/null +++ b/docs/app/components/command-k/components/search-input.tsx @@ -0,0 +1,47 @@ +import type { Ref } from "react" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" + +interface SearchInputProps { + value: string + onChange: (value: string) => void + placeholder: string + ref?: Ref +} + +export function SearchInput({ value, onChange, placeholder, ref }: SearchInputProps) { + return ( +
+ + onChange(e.target.value)} + placeholder={placeholder} + className={cn( + "flex-1 bg-transparent text-lg leading-6 outline-none", + "text-[var(--color-input-text)] placeholder-[var(--color-input-placeholder)]" + )} + autoComplete="off" + autoCorrect="off" + autoCapitalize="off" + spellCheck="false" + /> +
+ + ESC + +
+
+ ) +} diff --git a/docs/app/components/command-k/components/search-result.tsx b/docs/app/components/command-k/components/search-result.tsx new file mode 100644 index 0000000..03bf909 --- /dev/null +++ b/docs/app/components/command-k/components/search-result.tsx @@ -0,0 +1,86 @@ +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" +import type { MatchType, SearchRecord } from "../search-types" + +interface SearchResultProps { + item: SearchRecord + highlightedText: string + isSelected: boolean + onClick: () => void + matchType: MatchType +} + +const ResultIcon = ({ + isSelected, + matchType, +}: { + isSelected: boolean + matchType: MatchType +}) => { + const iconName = matchType === "heading" ? "Hash" : "Pilcrow" + + return ( +
+ +
+ ) +} + +const ResultTitle = ({ + title, + highlightedText, + isSelected, +}: { + title: string + highlightedText: string + isSelected: boolean +}) => ( +
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: rendering text */} + +
+) + +const ResultMetadata = ({ item, matchType }: Pick) => ( +
+ {item.title} + {matchType === "paragraph" && item.subtitle ? > {item.subtitle} : null} +
+) + +const ResultContent = ({ item, highlightedText, isSelected, matchType }: Omit) => ( +
+ + +
+) + +const useButtonStyles = (isSelected: boolean) => + cn( + "flex w-full items-start gap-3 border-r-2 px-4 py-3 text-left transition-all duration-150", + "hover:bg-[var(--color-result-hover)] focus:outline-none focus:ring-2 focus:ring-[var(--color-trigger-focus-ring)]", + isSelected + ? "border-[var(--color-result-selected-border)] bg-[var(--color-result-selected)] shadow-sm" + : "border-transparent" + ) + +export const SearchResultRow = ({ item, highlightedText, isSelected, onClick, matchType }: SearchResultProps) => { + const buttonStyles = useButtonStyles(isSelected) + + return ( + + ) +} diff --git a/docs/app/components/command-k/components/trigger-button.tsx b/docs/app/components/command-k/components/trigger-button.tsx new file mode 100644 index 0000000..5d1ba8e --- /dev/null +++ b/docs/app/components/command-k/components/trigger-button.tsx @@ -0,0 +1,45 @@ +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" + +export const TriggerButton = ({ + onOpen, + placeholder, +}: { + onOpen: () => void + placeholder: string +}) => ( + +) diff --git a/docs/app/components/command-k/create-search-index.ts b/docs/app/components/command-k/create-search-index.ts new file mode 100644 index 0000000..f410db0 --- /dev/null +++ b/docs/app/components/command-k/create-search-index.ts @@ -0,0 +1,127 @@ +import type { Page } from "content-collections-types" +import slug from "slug" +import { getPageSlug } from "~/utils/get-page-slug" + +function cleanParagraph(raw: string) { + return ( + raw + // strip inline code, bold, italics + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_(.+?)_/g, "$1") + // strip markdown links [text](url) + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + // strip mdx attributes { ... } inline + .replace(/\{[^}]*\}/g, "") + // list bullets / ordered list markers at line start + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + // collapse whitespace + .replace(/\n{2,}/g, "\n") + .replace(/[ \t]+/g, " ") + .trim() + ) +} + +function stripCodeFences(src: string) { + return src.replace(/```[\s\S]*?```/g, "") +} + +function splitIntoParagraphs(src: string) { + return src + .split(/\n\s*\n/g) + .map(cleanParagraph) + .filter((p) => p.length > 0) +} + +const extractHeadingData = (match: RegExpMatchArray) => { + const [fullMatch, hashes, text] = match + return { + level: hashes.length, + text, + index: match.index || 0, + length: fullMatch.length, + } +} + +function extractHeadingSections(rawMdx: string) { + const src = stripCodeFences(rawMdx) + const headingRegex = /^(#{1,6})\s+(.+?)\s*$/gm + const matches = Array.from(src.matchAll(headingRegex), extractHeadingData) + + const usedAnchors = new Set() + + const createUniqueAnchor = (baseAnchor: string) => { + let unique = baseAnchor + let n = 2 + while (usedAnchors.has(unique)) { + unique = `${baseAnchor}-${n++}` + } + usedAnchors.add(unique) + return unique + } + + const cleanHeadingText = (text: string) => + text + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\{[^}]*\}/g, "") + .trim() + + if (matches.length === 0) { + const paragraphs = splitIntoParagraphs(src) + return paragraphs.length ? [{ heading: "_intro", anchor: "_intro", paragraphs }] : [] + } + + const sections = [] + + // we are adding intro section if content exists before first heading + const introBlock = src.slice(0, matches[0].index).trim() + if (introBlock) { + const introParas = splitIntoParagraphs(introBlock) + if (introParas.length) { + sections.push({ heading: "_intro", anchor: "_intro", paragraphs: introParas }) + } + } + + matches.forEach((match, i) => { + const nextMatch = matches[i + 1] + const block = src.slice(match.index + match.length, nextMatch?.index).trim() + + const rawHeading = cleanHeadingText(match.text) + const baseAnchor = slug(rawHeading) || "_section" + const anchor = createUniqueAnchor(baseAnchor) + const paragraphs = splitIntoParagraphs(block) + + sections.push({ + heading: rawHeading, + anchor, + paragraphs, + }) + }) + + return sections +} + +export function createSearchIndex(pages: Page[]) { + return pages + .filter((page) => page.slug !== "_index") + .flatMap((page) => { + const pageSlug = getPageSlug(page) + const pageUrl = pageSlug.startsWith("/") ? pageSlug : `/${pageSlug}` + const sections = extractHeadingSections(page.rawMdx) + return sections.map((section) => { + const heading = section.heading === "_intro" ? page.title : section.heading + + return { + id: `${pageUrl}#${section.anchor}`, + title: page.title, + subtitle: heading, + paragraphs: [heading, ...section.paragraphs], + } + }) + }) +} diff --git a/docs/app/components/command-k/hooks/use-debounce.ts b/docs/app/components/command-k/hooks/use-debounce.ts new file mode 100644 index 0000000..1a01372 --- /dev/null +++ b/docs/app/components/command-k/hooks/use-debounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react" + +export function useDebounce(value: T, delay = 250) { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const id = setTimeout(() => setDebouncedValue(value), delay) + return () => clearTimeout(id) + }, [value, delay]) + + return debouncedValue +} diff --git a/docs/app/components/command-k/hooks/use-fuzzy-search.ts b/docs/app/components/command-k/hooks/use-fuzzy-search.ts new file mode 100644 index 0000000..a7b3f6c --- /dev/null +++ b/docs/app/components/command-k/hooks/use-fuzzy-search.ts @@ -0,0 +1,76 @@ +import type { FuzzySearchOptions, SearchRecord, SearchResult } from "../search-types" + +const DEFAULTS = { + threshold: 0.8, // results must score ≥ 0.8 to be considered relevant + minMatchCharLength: 2, // queries shorter than 2 chars are ignored +} + +const clamp = (n: number, min: number, max: number) => (n < min ? min : n > max ? max : n) +const toSearchable = (s: string) => s.toLowerCase().trim() +const escapeRegExp = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const scoreMatchQuality = (query: string, text: string) => { + const q = toSearchable(query) + const t = toSearchable(text) + if (q.length < DEFAULTS.minMatchCharLength) return 0 // ignore very short queries + if (t === q) return 1 // exact match -> strongest + if (t.startsWith(q)) return 0.95 // starts with query -> very strong + if (t.includes(q)) return 0.85 // contains query -> weaker + return 0 // no match +} + +const highlightSnippet = (text: string, query: string, maxLen = 120) => { + const trimmed = text.trim() + const q = toSearchable(query) + const idx = trimmed.toLowerCase().indexOf(q) + + if (idx === -1) { + // if no match, just return truncated text + return trimmed.length > maxLen ? `${trimmed.slice(0, maxLen)}...` : trimmed + } + + const half = Math.floor(maxLen / 2) + const start = Math.max(0, idx - half) + const end = Math.min(trimmed.length, idx + q.length + half) + const snippet = trimmed.slice(start, end) + + const safe = escapeRegExp(q) + const marked = snippet.replace( + new RegExp(`(${safe})`, "gi"), + `$1` + ) + + return `${start > 0 ? "..." : ""}${marked}${end < trimmed.length ? "..." : ""}` +} + +export function useFuzzySearch(items: SearchRecord[], query: string, options?: FuzzySearchOptions) { + const threshold = clamp(options?.threshold ?? DEFAULTS.threshold, 0, 1) + const minLen = Math.max(0, options?.minMatchCharLength ?? DEFAULTS.minMatchCharLength) + + const raw = query?.trim() + if (!raw || raw.length < minLen) return [] + + const results: SearchResult[] = [] + + for (const item of items) { + const paragraphs: ReadonlyArray = item.paragraphs ?? [] + + paragraphs.forEach((paragraph, paragraphIndex) => { + if (!paragraph) return + + const score = scoreMatchQuality(raw, paragraph) + + if (score >= threshold) { + results.push({ + item, + score: clamp(score, 0, 1), + matchedText: paragraph, + highlightedText: highlightSnippet(paragraph, raw), + refIndex: paragraphIndex, + }) + } + }) + } + + return results.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.refIndex - b.refIndex)) +} diff --git a/docs/app/components/command-k/hooks/use-keyboard-navigation.ts b/docs/app/components/command-k/hooks/use-keyboard-navigation.ts new file mode 100644 index 0000000..fb4d480 --- /dev/null +++ b/docs/app/components/command-k/hooks/use-keyboard-navigation.ts @@ -0,0 +1,73 @@ +import { useEffect, useState } from "react" +import type { SearchResult } from "../search-types" + +const KEYBOARD_SHORTCUTS = { + TOGGLE: "k", + ESCAPE: "Escape", + ARROW_DOWN: "ArrowDown", + ARROW_UP: "ArrowUp", + ENTER: "Enter", + TAB: "Tab", +} as const + +interface UseKeyboardNavigationProps { + isOpen: boolean + results: SearchResult[] + onSelect: (result: SearchResult) => void + onClose: () => void + onToggle: () => void +} + +export const useKeyboardNavigation = ({ isOpen, results, onSelect, onClose, onToggle }: UseKeyboardNavigationProps) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => { + setSelectedIndex(0) + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === KEYBOARD_SHORTCUTS.TOGGLE) { + e.preventDefault() + onToggle() + return + } + + if (e.key === KEYBOARD_SHORTCUTS.ESCAPE) { + onClose() + return + } + + if (!isOpen) return + + switch (e.key) { + case KEYBOARD_SHORTCUTS.ARROW_DOWN: + e.preventDefault() + setSelectedIndex((prev) => Math.min(prev + 1, results.length - 1)) + break + + case KEYBOARD_SHORTCUTS.ARROW_UP: + e.preventDefault() + setSelectedIndex((prev) => Math.max(prev - 1, 0)) + break + + case KEYBOARD_SHORTCUTS.ENTER: + e.preventDefault() + if (results[selectedIndex]) { + onSelect(results[selectedIndex]) + } + break + + case KEYBOARD_SHORTCUTS.TAB: + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % results.length) + break + } + } + + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, results, selectedIndex, onSelect, onClose, onToggle]) + + return { selectedIndex } +} diff --git a/docs/app/components/command-k/hooks/use-modal-state.ts b/docs/app/components/command-k/hooks/use-modal-state.ts new file mode 100644 index 0000000..68429e0 --- /dev/null +++ b/docs/app/components/command-k/hooks/use-modal-state.ts @@ -0,0 +1,16 @@ +import { useState } from "react" + +export const useModalState = (controlledIsOpen?: boolean, onOpenChange?: (open: boolean) => void) => { + const [internalIsOpen, setInternalIsOpen] = useState(false) + + const isOpen = controlledIsOpen ?? internalIsOpen + + const setIsOpen = (open: boolean) => { + onOpenChange ? onOpenChange(open) : setInternalIsOpen(open) + } + + const openModal = () => setIsOpen(true) + const closeModal = () => setIsOpen(false) + + return { isOpen, openModal, closeModal } +} diff --git a/docs/app/components/command-k/hooks/use-search-history.ts b/docs/app/components/command-k/hooks/use-search-history.ts new file mode 100644 index 0000000..092a639 --- /dev/null +++ b/docs/app/components/command-k/hooks/use-search-history.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react" +import { COMMAND_K_SEARCH_HISTORY, getStorageItem, removeStorageItem, setStorageItem } from "~/utils/local-storage" +import { normalizeVersion } from "~/utils/version-resolvers" +import type { HistoryItem } from "../search-types" + +const MAX_HISTORY_ITEMS = 10 + +function keyFor(version: string) { + const { version: v } = normalizeVersion(version) + return `${COMMAND_K_SEARCH_HISTORY}-${v}` +} + +export const useSearchHistory = (version: string) => { + const storageKey = keyFor(version) + const [history, setHistory] = useState([]) + + useEffect(() => { + try { + const stored = getStorageItem(storageKey) + if (!stored) { + setHistory([]) + return + } + const parsed = JSON.parse(stored) + setHistory(Array.isArray(parsed) ? parsed : []) + } catch (err) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.warn("Failed to load search history:", err) + setHistory([]) + } + }, [storageKey]) + + useEffect(() => { + try { + setStorageItem(storageKey, JSON.stringify(history)) + } catch (err) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.warn("Failed to save search history:", err) + } + }, [history, storageKey]) + + const addToHistory = (item: HistoryItem) => { + setHistory((prev) => { + const idx = prev.findIndex((h) => h.id === item.id) + if (idx >= 0) { + const existing = prev[idx] + const updated = { + ...existing, + type: item.type ?? existing.type, + title: item.title ?? existing.title, + subtitle: item.subtitle ?? existing.subtitle, + paragraphs: item.paragraphs ?? existing.paragraphs, + highlightedText: item.highlightedText ?? existing.highlightedText, + } + return [updated, ...prev.slice(0, idx), ...prev.slice(idx + 1)].slice(0, MAX_HISTORY_ITEMS) + } + return [item, ...prev].slice(0, MAX_HISTORY_ITEMS) + }) + } + + const clearHistory = () => { + setHistory([]) + removeStorageItem(storageKey) + } + + const removeFromHistory = (itemId: string) => { + setHistory((prev) => prev.filter((item) => item.id !== itemId)) + } + + return { history, addToHistory, clearHistory, removeFromHistory } +} diff --git a/docs/app/components/command-k/hooks/use-search.ts b/docs/app/components/command-k/hooks/use-search.ts new file mode 100644 index 0000000..607b86e --- /dev/null +++ b/docs/app/components/command-k/hooks/use-search.ts @@ -0,0 +1,62 @@ +import { useEffect, useRef, useState } from "react" +import { useFetcher } from "react-router" +import z from "zod" +import type { Version } from "~/utils/version-resolvers" +import { versions } from "~/utils/versions" +import type { SearchResult } from "../search-types" +import { useDebounce } from "./use-debounce" + +export const commandKSearchParamsSchema = z.object({ + query: z.string(), + version: z.enum(versions), +}) + +export type CommandKSearchParams = z.infer + +function createCommandKSearchParams(params: Record) { + const result = commandKSearchParamsSchema.safeParse(params) + if (!result.success) { + // biome-ignore lint/suspicious/noConsole: keep for debugging + console.error("Invalid parameters:", result.error) + return { params: null } + } + + return { params: new URLSearchParams(result.data) } +} + +const debounceMs = 250 +const minChars = 1 + +export function useSearch({ version }: { version: Version }) { + const fetcher = useFetcher<{ results: SearchResult[] }>() + const [query, setQuery] = useState("") + const debouncedQuery = useDebounce(query, debounceMs) + const lastLoadedRef = useRef(null) + + const results = query.trim() ? (fetcher.data?.results ?? []) : [] + + function search(q: string) { + setQuery(q) + } + + useEffect(() => { + const trimmed = debouncedQuery.trim() + if (!trimmed || trimmed.length < minChars) { + lastLoadedRef.current = null + return + } + + if (lastLoadedRef.current === trimmed) return + lastLoadedRef.current = trimmed + + const { params } = createCommandKSearchParams({ query: trimmed, version }) + if (!params) return + + fetcher.load(`/search?${params.toString()}`) + }, [debouncedQuery, version, fetcher]) + + return { + results, + search, + } +} diff --git a/docs/app/components/command-k/search-types.ts b/docs/app/components/command-k/search-types.ts new file mode 100644 index 0000000..f0bfcdd --- /dev/null +++ b/docs/app/components/command-k/search-types.ts @@ -0,0 +1,26 @@ +export interface SearchRecord { + id: string //e.g "/configuration/editor#name" where name is the heading inside of the editor page under the configuration section + title: string // page title + subtitle: string // title of the "sections" inside the page + paragraphs: string[] // for that id (section of the current page) get all paragraphs as an array of strings +} + +export interface SearchResult { + item: SearchRecord + score: number + matchedText: string + highlightedText: string + refIndex: number // 0 if heading, >0 if actual paragraph +} + +export interface FuzzySearchOptions { + threshold: number + minMatchCharLength: number +} + +export type MatchType = "heading" | "paragraph" + +export interface HistoryItem extends SearchRecord { + type?: MatchType + highlightedText?: string +} diff --git a/docs/app/components/github-contribute-links.tsx b/docs/app/components/github-contribute-links.tsx new file mode 100644 index 0000000..02fb3a6 --- /dev/null +++ b/docs/app/components/github-contribute-links.tsx @@ -0,0 +1,28 @@ +import { useTranslation } from "react-i18next" +import { useRouteLoaderData } from "react-router" +import { createGitHubContributionLinks } from "~/utils/create-github-contribution-links" + +const linkStyles = "hover:text-[var(--color-text-accent)] hover:underline" + +export default function GithubContributeLinks({ pagePath }: { pagePath: string }) { + const { clientEnv } = useRouteLoaderData("root") + const { t } = useTranslation() + + const { GITHUB_OWNER, GITHUB_REPO } = clientEnv + + if (!GITHUB_OWNER || !GITHUB_REPO) { + return null + } + + const { issueUrl, editUrl } = createGitHubContributionLinks({ pagePath, owner: GITHUB_OWNER, repo: GITHUB_REPO }) + return ( + + ) +} diff --git a/docs/app/components/header.tsx b/docs/app/components/header.tsx new file mode 100644 index 0000000..cb521a4 --- /dev/null +++ b/docs/app/components/header.tsx @@ -0,0 +1,19 @@ +import { cn } from "~/utils/css" + +interface HeaderProps { + children: React.ReactNode + className?: string +} + +export const Header = ({ children, className }: HeaderProps) => { + return ( +
+ {children} +
+ ) +} diff --git a/docs/app/components/icon-link.tsx b/docs/app/components/icon-link.tsx new file mode 100644 index 0000000..3d64596 --- /dev/null +++ b/docs/app/components/icon-link.tsx @@ -0,0 +1,28 @@ +import type { ComponentProps } from "react" +import { Icon } from "~/ui/icon/icon" +import type { IconName } from "~/ui/icon/icons/types" +import { cn } from "~/utils/css" + +interface IconLinkProps extends ComponentProps<"a"> { + name: IconName +} + +export const IconLink = ({ name, className, ...props }: IconLinkProps) => { + const { href } = props + const isExternal = typeof href === "string" && /^https?:\/\//i.test(href) + return ( + + + + ) +} diff --git a/docs/app/components/logo.tsx b/docs/app/components/logo.tsx new file mode 100644 index 0000000..7362223 --- /dev/null +++ b/docs/app/components/logo.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react" +import { href, useNavigate } from "react-router" + +export const Logo = ({ children }: { children: ReactNode }) => { + const navigate = useNavigate() + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: we don't need keyboard access for this +
navigate(href("/:version?/home"))} + className="relative block cursor-pointer font-semibold font-space text-[var(--color-text-active)] text-lg md:text-2xl xl:text-3xl" + > + {children} +
+ ) +} diff --git a/docs/app/components/mdx-wrapper.tsx b/docs/app/components/mdx-wrapper.tsx new file mode 100644 index 0000000..0d29f10 --- /dev/null +++ b/docs/app/components/mdx-wrapper.tsx @@ -0,0 +1,26 @@ +import { MDXContent } from "@content-collections/mdx/react" +import { Anchor } from "~/ui/anchor-tag" +import { InfoAlert } from "~/ui/info-alert" +import { InlineCode } from "~/ui/inline-code" +import { ListItem } from "~/ui/list-item" +import { OrderedList } from "~/ui/ordered-list" +import { Strong } from "~/ui/strong-text" +import { WarningAlert } from "~/ui/warning-alert" +import { CodeBlock } from "./code-block/code-block" + +export const MDXWrapper = ({ content }: { content: string }) => ( + +) diff --git a/docs/app/components/modal.tsx b/docs/app/components/modal.tsx new file mode 100644 index 0000000..6c2eef6 --- /dev/null +++ b/docs/app/components/modal.tsx @@ -0,0 +1,98 @@ +import { type ReactNode, useEffect, useRef } from "react" +import { useScrollLock } from "~/hooks/use-scroll-lock" +import { cn } from "~/utils/css" +import { Backdrop } from "./backdrop" + +interface ModalProps { + isOpen: boolean + onClose: () => void + children: ReactNode + className?: string + getInitialFocus?: () => HTMLElement | null + restoreFocus?: boolean + ariaLabel?: string +} + +export const Modal = ({ + isOpen, + onClose, + children, + className, + getInitialFocus, + restoreFocus = true, + ariaLabel, +}: ModalProps) => { + const modalRef = useRef(null) + const previouslyFocusedRef = useRef(null) + + useScrollLock(isOpen) + + useEffect(() => { + if (!isOpen) return + previouslyFocusedRef.current = document.activeElement as HTMLElement | null + return () => { + if (restoreFocus) previouslyFocusedRef.current?.focus?.() + } + }, [isOpen, restoreFocus]) + + useEffect(() => { + if (!isOpen) return + const id = requestAnimationFrame(() => { + const candidate = getInitialFocus?.() + if (candidate) { + candidate.focus() + return + } + const root = modalRef.current + if (!root) return + const firstFocusable = root.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ) + firstFocusable?.focus() + }) + return () => cancelAnimationFrame(id) + }, [isOpen, getInitialFocus]) + + useEffect(() => { + if (!isOpen) return + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + window.addEventListener("keydown", onKey) + return () => window.removeEventListener("keydown", onKey) + }, [isOpen, onClose]) + + const handleOverlayPointerDown = (e: React.PointerEvent) => { + const root = modalRef.current + if (!root) return + if (!root.contains(e.target as Node)) onClose() + } + + if (!isOpen) return null + + return ( + <> + +
+
+
e.stopPropagation()} + > + {children} +
+
+
+ + ) +} diff --git a/docs/app/components/page-mdx-article.tsx b/docs/app/components/page-mdx-article.tsx new file mode 100644 index 0000000..538cfdb --- /dev/null +++ b/docs/app/components/page-mdx-article.tsx @@ -0,0 +1,21 @@ +import type { Page } from "content-collections-types" +import { Title } from "~/ui/title" +import { MDXWrapper } from "./mdx-wrapper" + +export default function PageMdxArticle({ page }: { page: Page }) { + return ( +
+
+ + {page.title} + + {page.description && ( +

+ {page.description} +

+ )} +
+ +
+ ) +} diff --git a/docs/app/components/page-navigation.tsx b/docs/app/components/page-navigation.tsx new file mode 100644 index 0000000..dde6f3d --- /dev/null +++ b/docs/app/components/page-navigation.tsx @@ -0,0 +1,75 @@ +import clsx from "clsx" +import { useTranslation } from "react-i18next" +import { Link } from "react-router" +import { Icon } from "~/ui/icon/icon" +import { cn } from "~/utils/css" + +interface PageNavigationItem { + title: string + to: string +} + +interface PageNavigationProps { + previous?: PageNavigationItem + next?: PageNavigationItem +} + +interface PageNavigationLinkProps { + item: PageNavigationItem + direction: "previous" | "next" + label: string +} + +function PageNavigationLink({ item, direction, label }: PageNavigationLinkProps) { + const isPrevious = direction === "previous" + + return ( +
+
{label}
+ + {isPrevious &&
+ ) +} + +/** + * A pagination navigation component that displays "Previous" and "Next" links with + * accessible labels, styled arrows, and localized link text. + * + * It accepts optional `previous` and `next` props, each containing a `title` and `to` URL. + * When present, the component renders navigational links with arrow indicators. + * + * Example usage: + * + * + * @param previous - Optional previous page link data with title and path. + * @param next - Optional next page link data with title and path. + */ +export function PageNavigation({ previous, next }: PageNavigationProps) { + const { t } = useTranslation() + + return ( + + ) +} diff --git a/docs/app/components/sidebar/build-breadcrumbs.ts b/docs/app/components/sidebar/build-breadcrumbs.ts new file mode 100644 index 0000000..3a8ebe5 --- /dev/null +++ b/docs/app/components/sidebar/build-breadcrumbs.ts @@ -0,0 +1,41 @@ +import type { Page } from "content-collections-types" +import type { SidebarSection } from "~/utils/create-sidebar-tree" +import { buildDocPathFromSlug } from "~/utils/path-builders" + +export const buildBreadcrumbs = ( + items: SidebarSection[], + pathname: string, + documentationPages: Pick[] = [] +) => { + // for standalone pages: /:filename + for (const page of documentationPages) { + const docPath = buildDocPathFromSlug(page.slug) + if (docPath === pathname) { + return [page.title] + } + } + + // for sectioned pages: /:section/:subsection?/:filename + let trail: string[] = [] + + const walk = (section: SidebarSection, acc: string[]): boolean => { + for (const doc of section.documentationPages) { + const docPath = buildDocPathFromSlug(doc.slug) + if (docPath === pathname) { + trail = [...acc, section.title, doc.title] + return true + } + } + + for (const sub of section.subsections) { + if (walk(sub, [...acc, section.title])) return true + } + return false + } + + for (const root of items) { + if (walk(root, [])) break + } + + return trail +} diff --git a/docs/app/components/sidebar/desktop-sidebar.tsx b/docs/app/components/sidebar/desktop-sidebar.tsx new file mode 100644 index 0000000..e774ff2 --- /dev/null +++ b/docs/app/components/sidebar/desktop-sidebar.tsx @@ -0,0 +1,14 @@ +import type { SidebarTree } from "~/utils/create-sidebar-tree" +import { cn } from "~/utils/css" +import { SidebarContent } from "./sidebar-content" + +export const DesktopSidebarPanel = ({ sidebarTree, className }: { sidebarTree: SidebarTree; className: string }) => ( +
+ +
+) diff --git a/docs/app/components/sidebar/mobile-sidebar-context.tsx b/docs/app/components/sidebar/mobile-sidebar-context.tsx new file mode 100644 index 0000000..6573f75 --- /dev/null +++ b/docs/app/components/sidebar/mobile-sidebar-context.tsx @@ -0,0 +1,29 @@ +import { createContext, useContext, useState } from "react" + +interface MobileSidebarContextValue { + isOpen: boolean + open: () => void + close: () => void + toggle: () => void +} + +const MobileSidebarContext = createContext(null) + +export const MobileSidebarProvider = ({ children }: { children: React.ReactNode }) => { + const [isOpen, setOpen] = useState(false) + + const value: MobileSidebarContextValue = { + isOpen, + open: () => setOpen(true), + close: () => setOpen(false), + toggle: () => setOpen((prev) => !prev), + } + + return {children} +} + +export const useMobileSidebar = () => { + const ctx = useContext(MobileSidebarContext) + if (!ctx) throw new Error("Missing MobileSidebarProvider") + return ctx +} diff --git a/docs/app/components/sidebar/mobile-sidebar.tsx b/docs/app/components/sidebar/mobile-sidebar.tsx new file mode 100644 index 0000000..d5a5b97 --- /dev/null +++ b/docs/app/components/sidebar/mobile-sidebar.tsx @@ -0,0 +1,96 @@ +import { useParams } from "react-router" +import { useDocumentationLayoutLoaderData } from "~/hooks/use-documentation-layout-loader-data" +import { BreadcrumbItem, Breadcrumbs } from "~/ui/breadcrumbs" +import { IconButton } from "~/ui/icon-button" +import { Icon } from "~/ui/icon/icon" +import type { SidebarTree } from "~/utils/create-sidebar-tree" +import { cn } from "~/utils/css" +import { buildBreadcrumbs } from "./build-breadcrumbs" +import { useMobileSidebar } from "./mobile-sidebar-context" +import { SidebarContent } from "./sidebar-content" + +const MobileSidebarMenuButton = () => { + const { open } = useMobileSidebar() + + return ( + + ) +} + +export const MobileSidebarHeader = () => { + const params = useParams() + const { + sidebarTree: { sections, documentationPages }, + } = useDocumentationLayoutLoaderData() + const { section, subsection, filename } = params + const currentPath = `/${[section, subsection, filename].filter(Boolean).join("/")}` + const breadcrumbs = buildBreadcrumbs(sections, currentPath, documentationPages) + return ( +
+ + + {breadcrumbs.map((item) => ( + {item} + ))} + +
+ ) +} + +export const MobileSidebarOverlay = () => { + const { isOpen, close } = useMobileSidebar() + + return ( + // biome-ignore lint/a11y/useKeyWithClickEvents: We don't need keyboard support for this overlay +