From 00d05097d97759e07932865e751ebb8ad53a40af Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:18:35 -0300 Subject: [PATCH 01/21] fix(icons): fall back to default react-icon when config.json omits an icon key When gitpagedocs/config.json is partial or predates a new icon slot, the affected resolver computed useReactIcon via Boolean(site.Icon*ReactIcones), which is false for a missing key. That rendered the broken default image placeholder instead of the canonical react-icon. Resolvers now use `site.Icon*ReactIcones ?? !rawImage`: an explicit flag is still respected, but when the key is absent and no custom image is set, the default react-icon tag is used (the same icon a complete config.json ships). Covers ai-chat (10 slots), nav-menu factory, header, route-guide and audio-popover close/icons resolvers. --- .../lib/icons/ai-chat/resolve-ai-chat-icon.ts | 20 +++++++++---------- .../resolve-audio-popover-close-icon.ts | 2 +- .../resolve-audio-popover-icons.ts | 3 ++- .../lib/icons/header/resolve-header-icon.ts | 2 +- .../nav-menu/resolve-nav-menu-icon-factory.ts | 5 ++++- .../route-guide/resolve-route-guide-icon.ts | 2 +- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts b/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts index 03b9636..0e31414 100644 --- a/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts +++ b/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts @@ -29,7 +29,7 @@ export function resolveAiChatOpenIconConfig(site: any, mode: "dark" | "light", b }; } const rawImage = mode === "dark" ? site.IconAiChatOpenDarkImg : site.IconAiChatOpenLightImg; - const useReactIcon = Boolean(site.IconAiChatOpenReactIcones); + const useReactIcon = (site.IconAiChatOpenReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatOpenReactIconesTag || "BsChatDots"; const color = mode === "dark" ? site.IconAiChatOpenReactIconesTagColorDark : site.IconAiChatOpenReactIconesTagColorLight; const size = site.IconAiChatOpenReactIconesTagSize; @@ -55,7 +55,7 @@ export function resolveAiChatCloseIconConfig(site: any, mode: "dark" | "light", }; } const rawImage = mode === "dark" ? site.IconAiChatCloseDarkImg : site.IconAiChatCloseLightImg; - const useReactIcon = Boolean(site.IconAiChatCloseReactIcones); + const useReactIcon = (site.IconAiChatCloseReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatCloseReactIconesTag || "IoClose"; const color = mode === "dark" ? site.IconAiChatCloseReactIconesTagColorDark : site.IconAiChatCloseReactIconesTagColorLight; const size = site.IconAiChatCloseReactIconesTagSize; @@ -81,7 +81,7 @@ export function resolveAiChatSettingsIconConfig(site: any, mode: "dark" | "light }; } const rawImage = mode === "dark" ? site.IconAiChatSettingsDarkImg : site.IconAiChatSettingsLightImg; - const useReactIcon = Boolean(site.IconAiChatSettingsReactIcones); + const useReactIcon = (site.IconAiChatSettingsReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatSettingsReactIconesTag || "FiSettings"; const color = mode === "dark" ? site.IconAiChatSettingsReactIconesTagColorDark : site.IconAiChatSettingsReactIconesTagColorLight; const size = site.IconAiChatSettingsReactIconesTagSize; @@ -107,7 +107,7 @@ export function resolveAiChatSendIconConfig(site: any, mode: "dark" | "light", b }; } const rawImage = mode === "dark" ? site.IconAiChatSendDarkImg : site.IconAiChatSendLightImg; - const useReactIcon = Boolean(site.IconAiChatSendReactIcones); + const useReactIcon = (site.IconAiChatSendReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatSendReactIconesTag || "FiSend"; const color = mode === "dark" ? site.IconAiChatSendReactIconesTagColorDark : site.IconAiChatSendReactIconesTagColorLight; const size = site.IconAiChatSendReactIconesTagSize; @@ -133,7 +133,7 @@ export function resolveAiChatCancelIconConfig(site: any, mode: "dark" | "light", }; } const rawImage = mode === "dark" ? site.IconAiChatCancelDarkImg : site.IconAiChatCancelLightImg; - const useReactIcon = Boolean(site.IconAiChatCancelReactIcones); + const useReactIcon = (site.IconAiChatCancelReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatCancelReactIconesTag || "FiXCircle"; const color = mode === "dark" ? site.IconAiChatCancelReactIconesTagColorDark : site.IconAiChatCancelReactIconesTagColorLight; const size = site.IconAiChatCancelReactIconesTagSize; @@ -159,7 +159,7 @@ export function resolveAiChatTrashIconConfig(site: any, mode: "dark" | "light", }; } const rawImage = mode === "dark" ? site.IconAiChatTrashDarkImg : site.IconAiChatTrashLightImg; - const useReactIcon = Boolean(site.IconAiChatTrashReactIcones); + const useReactIcon = (site.IconAiChatTrashReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatTrashReactIconesTag || "FiTrash2"; const color = mode === "dark" ? site.IconAiChatTrashReactIconesTagColorDark : site.IconAiChatTrashReactIconesTagColorLight; const size = site.IconAiChatTrashReactIconesTagSize; @@ -185,7 +185,7 @@ export function resolveAiChatClearChatIconConfig(site: any, mode: "dark" | "ligh }; } const rawImage = mode === "dark" ? site.IconAiChatClearChatDarkImg : site.IconAiChatClearChatLightImg; - const useReactIcon = Boolean(site.IconAiChatClearChatReactIcones); + const useReactIcon = (site.IconAiChatClearChatReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatClearChatReactIconesTag || "FiMessageSquare"; const color = mode === "dark" ? site.IconAiChatClearChatReactIconesTagColorDark : site.IconAiChatClearChatReactIconesTagColorLight; const size = site.IconAiChatClearChatReactIconesTagSize; @@ -211,7 +211,7 @@ export function resolveAiChatClearDataIconConfig(site: any, mode: "dark" | "ligh }; } const rawImage = mode === "dark" ? site.IconAiChatClearDataDarkImg : site.IconAiChatClearDataLightImg; - const useReactIcon = Boolean(site.IconAiChatClearDataReactIcones); + const useReactIcon = (site.IconAiChatClearDataReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatClearDataReactIconesTag || "FiDatabase"; const color = mode === "dark" ? site.IconAiChatClearDataReactIconesTagColorDark : site.IconAiChatClearDataReactIconesTagColorLight; const size = site.IconAiChatClearDataReactIconesTagSize; @@ -237,7 +237,7 @@ export function resolveAiChatExpandIconConfig(site: any, mode: "dark" | "light", }; } const rawImage = mode === "dark" ? site.IconAiChatExpandDarkImg : site.IconAiChatExpandLightImg; - const useReactIcon = Boolean(site.IconAiChatExpandReactIcones); + const useReactIcon = (site.IconAiChatExpandReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatExpandReactIconesTag || "FiMaximize2"; const color = mode === "dark" ? site.IconAiChatExpandReactIconesTagColorDark : site.IconAiChatExpandReactIconesTagColorLight; const size = site.IconAiChatExpandReactIconesTagSize; @@ -263,7 +263,7 @@ export function resolveAiChatCollapseIconConfig(site: any, mode: "dark" | "light }; } const rawImage = mode === "dark" ? site.IconAiChatCollapseDarkImg : site.IconAiChatCollapseLightImg; - const useReactIcon = Boolean(site.IconAiChatCollapseReactIcones); + const useReactIcon = (site.IconAiChatCollapseReactIcones ?? !rawImage); const reactIconTag = site.IconAiChatCollapseReactIconesTag || "FiMinimize2"; const color = mode === "dark" ? site.IconAiChatCollapseReactIconesTagColorDark : site.IconAiChatCollapseReactIconesTagColorLight; const size = site.IconAiChatCollapseReactIconesTagSize; diff --git a/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts b/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts index 38ac509..ba54803 100644 --- a/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts +++ b/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts @@ -38,7 +38,7 @@ export function resolveAudioPlayerPopoverCloseIconConfig( ? site.IconAudioPlayerPopoverCloseDarkImg?.trim() : site.IconAudioPlayerPopoverCloseLightImg?.trim(); const iconImage = resolveIconPath(rawIconImage || DEFAULT_IMG, basePath); - const useReactIcon = Boolean(site.IconAudioPlayerPopoverCloseReactIcones); + const useReactIcon = (site.IconAudioPlayerPopoverCloseReactIcones ?? !rawIconImage); const reactIconTag = site.IconAudioPlayerPopoverCloseReactIconesTag?.trim() || FALLBACK_TAG; const reactIconColor = mode === "dark" diff --git a/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-icons.ts b/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-icons.ts index e915868..f2235c4 100644 --- a/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-icons.ts +++ b/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-icons.ts @@ -84,7 +84,8 @@ function resolveAudioPopoverIcon( ? (s[`${prefix}DarkImg`] as string)?.trim() : (s[`${prefix}LightImg`] as string)?.trim(); const iconImage = resolveIconPath(rawIconImage || DEFAULT_IMG, basePath); - const useReactIcon = Boolean(s[`${prefix}ReactIcones`]); + const useReactIcon = + (s[`${prefix}ReactIcones`] as boolean | undefined) ?? !rawIconImage; const reactIconTag = (s[`${prefix}ReactIconesTag`] as string)?.trim() || fallbackTag; const reactIconColor = mode === "dark" diff --git a/frontend/src/shared/lib/icons/header/resolve-header-icon.ts b/frontend/src/shared/lib/icons/header/resolve-header-icon.ts index f6ebc21..82bcf46 100644 --- a/frontend/src/shared/lib/icons/header/resolve-header-icon.ts +++ b/frontend/src/shared/lib/icons/header/resolve-header-icon.ts @@ -67,7 +67,7 @@ export function resolveHeaderIconConfig( site.IconImageMenuHeader?.trim() || site.SiteIconPath?.trim(); const iconImage = resolveIconPath(rawIconImage, basePath); - const useReactIcon = Boolean(site.IconImageMenuHeaderReactIcones); + const useReactIcon = (site.IconImageMenuHeaderReactIcones ?? !rawIconImage); const reactIconTag = site.IconImageMenuHeaderReactIconesTag; const reactIconColor = mode === "dark" diff --git a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts index 6a842d8..85504ca 100644 --- a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts +++ b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts @@ -224,7 +224,10 @@ function resolveIconFromKeys( ? (site[keys.darkKey] as string)?.trim() : (site[keys.lightKey] as string)?.trim(); const iconImage = resolveIconPath(rawIconImage || DEFAULT_IMG, basePath); - const useReactIcon = Boolean(site[keys.useReactKey]); + // Fallback to the default react-icon when the config provides nothing for + // this slot (no explicit flag and no custom image) — instead of a broken + // default image. An explicit flag (true/false) is always respected. + const useReactIcon = (site[keys.useReactKey] as boolean | undefined) ?? !rawIconImage; const reactIconTag = (site[keys.tagKey] as string) || keys.fallbackTag; const reactIconColor = mode === "dark" diff --git a/frontend/src/shared/lib/icons/route-guide/resolve-route-guide-icon.ts b/frontend/src/shared/lib/icons/route-guide/resolve-route-guide-icon.ts index 3a194d2..8235a35 100644 --- a/frontend/src/shared/lib/icons/route-guide/resolve-route-guide-icon.ts +++ b/frontend/src/shared/lib/icons/route-guide/resolve-route-guide-icon.ts @@ -44,7 +44,7 @@ export function resolveRouteGuideIconConfig( const iconImage = rawIconImage?.startsWith("http") ? rawIconImage : resolveIconPath(undefined, basePath); - const useReactIcon = Boolean(site.IconRouteGuideReactIcones); + const useReactIcon = (site.IconRouteGuideReactIcones ?? !rawIconImage); const reactIconTag = site.IconRouteGuideReactIconesTag; const reactIconColor = isDarkMode ? site.IconRouteGuideReactIconesTagColorDark From 2a2952e62cdf80f2ae7ac91b50af639937ba35ae Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:23:09 -0300 Subject: [PATCH 02/21] fix(release): pin internal deps to semver ranges so published packages install The published @gitpagedocs/cli@1.1.45 and @gitpagedocs/mcp@1.1.44 carried `@gitpagedocs/tools: workspace:*` / `@gitpagedocs/mcp: workspace:*` in their dependencies, because they were released with `npm publish`, which (unlike `pnpm publish`) does not rewrite the `workspace:` protocol. Installing them then failed with EUNSUPPORTEDPROTOCOL "Unsupported URL Type workspace:". Replace the internal `workspace:*` deps in cli and mcp with plain semver ranges (^1.1.47) so the published artifact resolves on npm no matter which tool publishes it. A root .npmrc with link-workspace-packages keeps pnpm linking the in-repo packages locally during development despite the ranges. Bump tools, mcp and cli to 1.1.47 for a clean coordinated re-release (publish order: tools -> mcp -> cli). --- .npmrc | 7 +++++++ cli/package.json | 6 +++--- mcp/package.json | 4 ++-- pnpm-lock.yaml | 6 +++--- tools/package.json | 2 +- 5 files changed, 16 insertions(+), 9 deletions(-) create mode 100644 .npmrc diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..41d363a --- /dev/null +++ b/.npmrc @@ -0,0 +1,7 @@ +# Keep pnpm linking the in-repo workspace packages (@gitpagedocs/tools, /mcp) +# even though their dependents declare plain semver ranges (^1.1.x) instead of +# the `workspace:*` protocol. Plain ranges are used so the PUBLISHED packages +# carry resolvable npm deps regardless of whether they are released with +# `pnpm publish` or `npm publish` (npm does not rewrite `workspace:*`). +link-workspace-packages=true +prefer-workspace-packages=true diff --git a/cli/package.json b/cli/package.json index e10a263..128a290 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/cli", - "version": "1.1.45", + "version": "1.1.47", "description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.", "bin": { "gitpagedocs": "index.mjs" @@ -29,8 +29,8 @@ }, "dependencies": { "@clack/prompts": "^1.5.1", - "@gitpagedocs/mcp": "workspace:*", - "@gitpagedocs/tools": "workspace:*", + "@gitpagedocs/mcp": "^1.1.47", + "@gitpagedocs/tools": "^1.1.47", "tsx": "^4.21.0" }, "repository": { diff --git a/mcp/package.json b/mcp/package.json index 0a4e381..ea46820 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/mcp", - "version": "1.1.45", + "version": "1.1.47", "type": "module", "description": "Model Context Protocol server for Git Page Docs (delegates to @gitpagedocs/tools). Ships TypeScript source; run via tsx.", "main": "./src/index.ts", @@ -16,7 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@gitpagedocs/tools": "workspace:*", + "@gitpagedocs/tools": "^1.1.47", "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 772e065..2a83e52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,10 +78,10 @@ importers: specifier: ^1.5.1 version: 1.5.1 '@gitpagedocs/mcp': - specifier: workspace:* + specifier: ^1.1.47 version: link:../mcp '@gitpagedocs/tools': - specifier: workspace:* + specifier: ^1.1.47 version: link:../tools tsx: specifier: ^4.21.0 @@ -124,7 +124,7 @@ importers: mcp: dependencies: '@gitpagedocs/tools': - specifier: workspace:* + specifier: ^1.1.47 version: link:../tools '@modelcontextprotocol/sdk': specifier: ^1.12.0 diff --git a/tools/package.json b/tools/package.json index f2033e8..928440d 100644 --- a/tools/package.json +++ b/tools/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/tools", - "version": "1.1.45", + "version": "1.1.47", "type": "module", "description": "Shared business-logic core for Git Page Docs (consumed by frontend, cli and mcp). Ships TypeScript source; consume via tsx or a TS-aware bundler.", "main": "./src/index.ts", From b434f72855b5e99912492a73a2924396c9d67b46 Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:26:41 -0300 Subject: [PATCH 03/21] fix(cli): resolve tsx from the package location, not the user's cwd `node --import tsx` resolved the bare "tsx" specifier relative to the current working directory. When the CLI is installed globally and run from a project that has no local tsx (e.g. a fresh docs repo), this failed with ERR_MODULE_NOT_FOUND "Cannot find package 'tsx' imported from ". Resolve tsx via import.meta.resolve (relative to cli/index.mjs, where tsx is a real dependency) and pass the absolute URL to --import, so the loader is found regardless of the working directory. Falls back to the bare specifier on older Node without a stable import.meta.resolve. --- cli/index.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cli/index.mjs b/cli/index.mjs index 525ff91..2c57b35 100644 --- a/cli/index.mjs +++ b/cli/index.mjs @@ -8,9 +8,21 @@ const scriptPath = fileURLToPath(import.meta.url); const scriptDir = path.dirname(scriptPath); const tsEntry = path.join(scriptDir, "presentation", "index.ts"); +// Resolve tsx from THIS package's location, not the user's cwd. `node --import +// tsx` would otherwise resolve the bare "tsx" specifier relative to the current +// working directory, which fails when the CLI is installed globally and run +// from a project that doesn't depend on tsx (ERR_MODULE_NOT_FOUND 'tsx'). +let tsxLoader = "tsx"; +try { + tsxLoader = import.meta.resolve("tsx"); +} catch { + // Older Node without a stable import.meta.resolve — fall back to the bare + // specifier (works when tsx is resolvable from the cwd or globally). +} + const result = spawnSync( process.execPath, - ["--import", "tsx", tsEntry, ...process.argv.slice(2)], + ["--import", tsxLoader, tsEntry, ...process.argv.slice(2)], { stdio: "inherit", cwd: process.cwd(), From e2ee3f6b2fac379b5c8b5cc11225728a27d9f5be Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:33:01 -0300 Subject: [PATCH 04/21] chore(cli): bump to 1.1.48 to ship the tsx-resolution fix The published 1.1.47 was cut before the tsx bootstrap fix landed, so its index.mjs still resolves the bare "tsx" specifier from the cwd and crashes with ERR_MODULE_NOT_FOUND on a global run. npm forbids overwriting 1.1.47, so re-release the CLI as 1.1.48 (tools/mcp 1.1.47 are unaffected). Internal deps stay ^1.1.47. --- cli/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/package.json b/cli/package.json index 128a290..43621c7 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/cli", - "version": "1.1.47", + "version": "1.1.48", "description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.", "bin": { "gitpagedocs": "index.mjs" From 4594720946b4755fa3119bf85faaa88ae0aba94d Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:43:13 -0300 Subject: [PATCH 05/21] fix(icons): align fallback react-icon tags with config.json; default repositorySearchHome to false - Replace the broken IoClose fallback with IoMdClose everywhere (ai-chat open/close resolver, audio-popover close, nav-menu close + mobile close, and the ai-chat-drawer render fallback). IoClose is not exported by react-icons/io, so the fallback rendered nothing; config.json uses IoMdClose. - Give the header icon a FaGithubAlt fallback (matching config.json) instead of an undefined tag when the key is absent, including the no-site branch. - CLI root-config-builder now emits repositorySearchHome: false by default, and the bundled gitpagedocs/config.json is updated to match. The fallback tags now mirror exactly what a complete config.json ships. --- cli/builders/root-config-builder.mjs | 2 +- .../src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts | 4 ++-- .../icons/audio-popover/resolve-audio-popover-close-icon.ts | 2 +- frontend/src/shared/lib/icons/header/resolve-header-icon.ts | 6 +++--- .../lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts | 4 ++-- frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx | 2 +- gitpagedocs/config.json | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/cli/builders/root-config-builder.mjs b/cli/builders/root-config-builder.mjs index d7dd25b..1676454 100644 --- a/cli/builders/root-config-builder.mjs +++ b/cli/builders/root-config-builder.mjs @@ -11,7 +11,7 @@ export function buildRootConfig(options = {}) { const useOfficialLayouts = !useLocalLayoutConfig; const githubOwner = options.githubOwner; const githubRepo = options.githubRepo; - const repositorySearchHome = githubOwner && githubRepo ? false : true; + const repositorySearchHome = false; const renderingUrl = githubOwner && githubRepo ? `https://${githubOwner}.github.io/${githubRepo}/` diff --git a/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts b/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts index 0e31414..b82c5fb 100644 --- a/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts +++ b/frontend/src/shared/lib/icons/ai-chat/resolve-ai-chat-icon.ts @@ -48,7 +48,7 @@ export function resolveAiChatCloseIconConfig(site: any, mode: "dark" | "light", return { iconImage: resolveIconPath(DEFAULT_IMG, basePath), useReactIcon: true, - reactIconTag: "IoClose", + reactIconTag: "IoMdClose", reactIconStyle: {}, iconImgWidth: 20, iconImgHeight: 20, @@ -56,7 +56,7 @@ export function resolveAiChatCloseIconConfig(site: any, mode: "dark" | "light", } const rawImage = mode === "dark" ? site.IconAiChatCloseDarkImg : site.IconAiChatCloseLightImg; const useReactIcon = (site.IconAiChatCloseReactIcones ?? !rawImage); - const reactIconTag = site.IconAiChatCloseReactIconesTag || "IoClose"; + const reactIconTag = site.IconAiChatCloseReactIconesTag || "IoMdClose"; const color = mode === "dark" ? site.IconAiChatCloseReactIconesTagColorDark : site.IconAiChatCloseReactIconesTagColorLight; const size = site.IconAiChatCloseReactIconesTagSize; return { diff --git a/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts b/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts index ba54803..c64ffb3 100644 --- a/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts +++ b/frontend/src/shared/lib/icons/audio-popover/resolve-audio-popover-close-icon.ts @@ -16,7 +16,7 @@ export interface AudioPlayerPopoverCloseIconConfigInput { } const DEFAULT_IMG = DEFAULT_ICON_FALLBACK_URL; -const FALLBACK_TAG = "IoClose"; +const FALLBACK_TAG = "IoMdClose"; export function resolveAudioPlayerPopoverCloseIconConfig( site: AudioPlayerPopoverCloseIconConfigInput | undefined, diff --git a/frontend/src/shared/lib/icons/header/resolve-header-icon.ts b/frontend/src/shared/lib/icons/header/resolve-header-icon.ts index 82bcf46..9a35a22 100644 --- a/frontend/src/shared/lib/icons/header/resolve-header-icon.ts +++ b/frontend/src/shared/lib/icons/header/resolve-header-icon.ts @@ -53,8 +53,8 @@ export function resolveHeaderIconConfig( return { iconImage: resolveIconPath(undefined, basePath), headerName, - useReactIcon: false, - reactIconTag: undefined, + useReactIcon: true, + reactIconTag: "FaGithubAlt", reactIconStyle: {}, iconImgWidth: 20, iconImgHeight: 20, @@ -68,7 +68,7 @@ export function resolveHeaderIconConfig( site.SiteIconPath?.trim(); const iconImage = resolveIconPath(rawIconImage, basePath); const useReactIcon = (site.IconImageMenuHeaderReactIcones ?? !rawIconImage); - const reactIconTag = site.IconImageMenuHeaderReactIconesTag; + const reactIconTag = site.IconImageMenuHeaderReactIconesTag || "FaGithubAlt"; const reactIconColor = mode === "dark" ? site.IconImageMenuHeaderReactIconesTagColorDark diff --git a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts index 85504ca..26b7048 100644 --- a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts +++ b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts @@ -127,7 +127,7 @@ const NAV_MENU_ICON_KEYS: Record = { sizeKey: "IconNavMenuCloseReactIconesTagSize", widthKey: "IconNavMenuCloseImgWidth", heightKey: "IconNavMenuCloseImgHeight", - fallbackTag: "IoClose", + fallbackTag: "IoMdClose", }, mobileOpen: { lightKey: "IconNavMenuMobileOpenLightImg", @@ -151,7 +151,7 @@ const NAV_MENU_ICON_KEYS: Record = { sizeKey: "IconNavMenuMobileCloseReactIconesTagSize", widthKey: "IconNavMenuMobileCloseImgWidth", heightKey: "IconNavMenuMobileCloseImgHeight", - fallbackTag: "IoClose", + fallbackTag: "IoMdClose", }, blockActive: { lightKey: "IconNavMenuBlockActiveLightImg", diff --git a/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx b/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx index 9e25ff2..856410e 100644 --- a/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx +++ b/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx @@ -307,7 +307,7 @@ export const AiChatDrawer: React.FC = ({ isOpen, onClose, ico title={labels.aiChatCloseBtnAriaLabel} className={styles.closeButton} > - {renderIcon(icons.close, "IoClose")} + {renderIcon(icons.close, "IoMdClose")} diff --git a/gitpagedocs/config.json b/gitpagedocs/config.json index f59393d..2561942 100644 --- a/gitpagedocs/config.json +++ b/gitpagedocs/config.json @@ -323,7 +323,7 @@ "layoutsConfigPathOficial": true, "layoutsConfigPathTemplatesOficial": "https://github.com/Vidigal-code/git-page-docs/blob/main/gitpagedocs/layouts/templates", "layoutsConfigPathOficialUrl": "https://github.com/Vidigal-code/git-page-docs/blob/main/gitpagedocs/layouts/layoutsConfig.json", - "repositorySearchHome": true, + "repositorySearchHome": false, "rendering": "https://vidigal-code.github.io/git-page-docs/", "AiChatEnabled": true, "langmenu": { From d7cd9e32d9bf12191314ee69bd6df1641d3bd662 Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:43:26 -0300 Subject: [PATCH 06/21] chore(release): bump tools, mcp and cli to 1.1.48 Coordinated release so the icon/config fixes and the tsx-resolution fix ship together. Internal deps now reference ^1.1.48 (tools, mcp). --- cli/package.json | 6 +++--- mcp/package.json | 4 ++-- tools/package.json | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/package.json b/cli/package.json index 43621c7..ce22f32 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/cli", - "version": "1.1.48", + "version": "1.1.49", "description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.", "bin": { "gitpagedocs": "index.mjs" @@ -29,8 +29,8 @@ }, "dependencies": { "@clack/prompts": "^1.5.1", - "@gitpagedocs/mcp": "^1.1.47", - "@gitpagedocs/tools": "^1.1.47", + "@gitpagedocs/mcp": "^1.1.49", + "@gitpagedocs/tools": "^1.1.49", "tsx": "^4.21.0" }, "repository": { diff --git a/mcp/package.json b/mcp/package.json index ea46820..80ccc36 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/mcp", - "version": "1.1.47", + "version": "1.1.49", "type": "module", "description": "Model Context Protocol server for Git Page Docs (delegates to @gitpagedocs/tools). Ships TypeScript source; run via tsx.", "main": "./src/index.ts", @@ -16,7 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@gitpagedocs/tools": "^1.1.47", + "@gitpagedocs/tools": "^1.1.49", "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.23.8" }, diff --git a/tools/package.json b/tools/package.json index 928440d..c32cd07 100644 --- a/tools/package.json +++ b/tools/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/tools", - "version": "1.1.47", + "version": "1.1.49", "type": "module", "description": "Shared business-logic core for Git Page Docs (consumed by frontend, cli and mcp). Ships TypeScript source; consume via tsx or a TS-aware bundler.", "main": "./src/index.ts", From f75b5ffdf4ca9ce0c8b21ae13d8e25053f02c674 Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:43:55 -0300 Subject: [PATCH 07/21] chore: sync pnpm-lock for 1.1.48 internal dep ranges --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a83e52..4be73db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,10 +78,10 @@ importers: specifier: ^1.5.1 version: 1.5.1 '@gitpagedocs/mcp': - specifier: ^1.1.47 + specifier: ^1.1.49 version: link:../mcp '@gitpagedocs/tools': - specifier: ^1.1.47 + specifier: ^1.1.49 version: link:../tools tsx: specifier: ^4.21.0 @@ -124,7 +124,7 @@ importers: mcp: dependencies: '@gitpagedocs/tools': - specifier: ^1.1.47 + specifier: ^1.1.49 version: link:../tools '@modelcontextprotocol/sdk': specifier: ^1.12.0 From 97a6f42a52f4bf615a087abf173c8a0423eeddfc Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:54:25 -0300 Subject: [PATCH 08/21] feat(cli): prompt for owner/repo on deploy instead of crashing Running `gitpagedocs deploy` (or `--push`) without --owner/--repo threw "`--push` requires owner and repo". In an interactive TTY it now prompts for them via @clack/prompts, pre-filling from the git `origin` remote when one exists (detectRepoFromGit). In CI / non-TTY the existing hard error still guards, so automated pipelines fail fast instead of hanging on a prompt. --- cli/presentation/options/resolver.ts | 18 ++++++++++++- cli/presentation/ui/prompts.ts | 38 +++++++++++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/cli/presentation/options/resolver.ts b/cli/presentation/options/resolver.ts index e8bd991..c896680 100644 --- a/cli/presentation/options/resolver.ts +++ b/cli/presentation/options/resolver.ts @@ -1,7 +1,15 @@ import type { CliOptions } from "../../domain/models/cli-options"; import { parseCliOptions } from "./parser"; -import { shouldRunInteractive, promptConfigOnlyOptions, promptHomeOptions } from "../ui/prompts"; +import { + shouldRunInteractive, + promptConfigOnlyOptions, + promptHomeOptions, + promptDeployOptions, + interactivePromptsAvailable, +} from "../ui/prompts"; import { DEFAULTS } from "./schema"; +// @ts-expect-error .mjs runtime module is type-less in this package. +import { detectRepoFromGit } from "../../runtime/git-ops.mjs"; export async function resolveOptions(argv: string[], env: NodeJS.ProcessEnv): Promise { const parsed = parseCliOptions(argv, env); @@ -11,6 +19,14 @@ export async function resolveOptions(argv: string[], env: NodeJS.ProcessEnv): Pr parsed.basePath = parsed.basePath ?? parsed.docsPath ?? DEFAULTS.home.basePath; } + // `deploy` / `--push` need owner + repo. If absent, prompt interactively + // (pre-filled from the git origin remote) instead of throwing. In CI/non-TTY + // we fall through so the explicit "requires owner and repo" guard still fires. + if (parsed.shouldPush && (!parsed.githubOwner || !parsed.githubRepo) && interactivePromptsAvailable()) { + const detected = detectRepoFromGit(process.cwd()) as { owner: string; repo: string } | null; + return promptDeployOptions(parsed, detected); + } + const runHomeInteractive = parsed.isInteractive || (shouldRunInteractive(argv) && parsed.mode === "home"); if (runHomeInteractive && parsed.mode === "home") { return promptHomeOptions(parsed); diff --git a/cli/presentation/ui/prompts.ts b/cli/presentation/ui/prompts.ts index 01e62e4..3f5c0d6 100644 --- a/cli/presentation/ui/prompts.ts +++ b/cli/presentation/ui/prompts.ts @@ -1,6 +1,6 @@ import type { CliOptions } from "../../domain/models/cli-options"; import { DEFAULTS } from "../options/schema"; -import { askText, askConfirm } from "./clack"; +import { askText, askConfirm, note } from "./clack"; function isCiOrNonTty(): boolean { if (process.env.CI === "true") return true; @@ -9,6 +9,11 @@ function isCiOrNonTty(): boolean { return false; } +/** True when clack prompts can run (interactive TTY, not CI). */ +export function interactivePromptsAvailable(): boolean { + return !isCiOrNonTty(); +} + export function shouldRunInteractive(argv: string[]): boolean { if (isCiOrNonTty()) return false; const args = argv.slice(2); @@ -41,6 +46,37 @@ export async function promptHomeOptions(parsed: CliOptions): Promise }; } +/** + * Deploy/--push needs a GitHub owner + repo. When they are missing, prompt for + * them (pre-filled from the git `origin` remote when available) instead of + * crashing with "`--push` requires owner and repo". + */ +export async function promptDeployOptions( + parsed: CliOptions, + detected: { owner: string; repo: string } | null, +): Promise { + note( + "Deploy publishes gitpagedocs to GitHub Pages and needs the target\nrepository (owner/repo). Press Enter to accept a detected value.", + "GitHub Pages deploy", + ); + const githubOwner = await askText({ + message: "GitHub owner (user or organization):", + defaultValue: parsed.githubOwner || detected?.owner || "", + validate: (v) => (v && v.trim() ? undefined : "Owner is required to deploy."), + }); + const githubRepo = await askText({ + message: "GitHub repository name:", + defaultValue: parsed.githubRepo || detected?.repo || "", + validate: (v) => (v && v.trim() ? undefined : "Repository is required to deploy."), + }); + + return { + ...parsed, + githubOwner: githubOwner.trim(), + githubRepo: githubRepo.trim(), + }; +} + export async function promptConfigOnlyOptions(parsed: CliOptions): Promise { const useLocalLayoutConfig = await askConfirm( "Generate local layout templates?", From ac3d35b2811308088fb138551d1412c0311b1200 Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 21:57:45 -0300 Subject: [PATCH 09/21] chore(release): bump tools, mcp and cli to 1.1.50 1.1.49 was already published to npm before the interactive deploy prompt landed, and npm versions are immutable. Re-release all three at 1.1.50 so the deploy/--push owner+repo prompt ships. Internal deps reference ^1.1.50. --- cli/package.json | 6 +++--- mcp/package.json | 4 ++-- pnpm-lock.yaml | 6 +++--- tools/package.json | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/cli/package.json b/cli/package.json index ce22f32..e7eacb3 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/cli", - "version": "1.1.49", + "version": "1.1.50", "description": "CLI that scaffolds and maintains gitpagedocs documentation, generates docs with AI, configures GitHub Pages, and runs the MCP server.", "bin": { "gitpagedocs": "index.mjs" @@ -29,8 +29,8 @@ }, "dependencies": { "@clack/prompts": "^1.5.1", - "@gitpagedocs/mcp": "^1.1.49", - "@gitpagedocs/tools": "^1.1.49", + "@gitpagedocs/mcp": "^1.1.50", + "@gitpagedocs/tools": "^1.1.50", "tsx": "^4.21.0" }, "repository": { diff --git a/mcp/package.json b/mcp/package.json index 80ccc36..7693847 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/mcp", - "version": "1.1.49", + "version": "1.1.50", "type": "module", "description": "Model Context Protocol server for Git Page Docs (delegates to @gitpagedocs/tools). Ships TypeScript source; run via tsx.", "main": "./src/index.ts", @@ -16,7 +16,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@gitpagedocs/tools": "^1.1.49", + "@gitpagedocs/tools": "^1.1.50", "@modelcontextprotocol/sdk": "^1.12.0", "zod": "^3.23.8" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4be73db..2580397 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -78,10 +78,10 @@ importers: specifier: ^1.5.1 version: 1.5.1 '@gitpagedocs/mcp': - specifier: ^1.1.49 + specifier: ^1.1.50 version: link:../mcp '@gitpagedocs/tools': - specifier: ^1.1.49 + specifier: ^1.1.50 version: link:../tools tsx: specifier: ^4.21.0 @@ -124,7 +124,7 @@ importers: mcp: dependencies: '@gitpagedocs/tools': - specifier: ^1.1.49 + specifier: ^1.1.50 version: link:../tools '@modelcontextprotocol/sdk': specifier: ^1.12.0 diff --git a/tools/package.json b/tools/package.json index c32cd07..82a8d36 100644 --- a/tools/package.json +++ b/tools/package.json @@ -1,6 +1,6 @@ { "name": "@gitpagedocs/tools", - "version": "1.1.49", + "version": "1.1.50", "type": "module", "description": "Shared business-logic core for Git Page Docs (consumed by frontend, cli and mcp). Ships TypeScript source; consume via tsx or a TS-aware bundler.", "main": "./src/index.ts", From cd40c6a8aafb3e20aaf93bf05898ec3e3112fe0d Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 22:03:43 -0300 Subject: [PATCH 10/21] feat(cli): make the rest of the deploy flow interactive instead of crashing Extends the deploy/--push interactive recovery so missing prerequisites prompt (in a TTY) rather than throwing the same class of error as the owner/repo case: - Offer `git init` when the folder is not a git repository (ensureGitRepoInteractive), covering both the prompted path and runs where owner/repo came from flags. - `pages actions` and `pages deploy` now prompt for owner/repo when they can't be detected from the git remote, instead of printing a dead-end message. - Share one askOwnerRepo helper between the legacy deploy prompt and the pages verbs. - Fix the `pages deploy` bin path (pkgRoot is already cli/, so spawn cli/index.mjs). CI/non-TTY keeps the explicit guards (no hangs); verified deploy still fails fast without a TTY. --- cli/presentation/commands/pages.ts | 26 ++++++++---- cli/presentation/options/resolver.ts | 19 ++++++--- cli/presentation/ui/prompts.ts | 60 +++++++++++++++++++++++----- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/cli/presentation/commands/pages.ts b/cli/presentation/commands/pages.ts index 8db0767..b2ee633 100644 --- a/cli/presentation/commands/pages.ts +++ b/cli/presentation/commands/pages.ts @@ -2,6 +2,7 @@ import path from "node:path"; import { spawnSync } from "node:child_process"; import type { CommandContext } from "./run-command"; import { askConfirm } from "../ui/clack"; +import { askOwnerRepo, interactivePromptsAvailable } from "../ui/prompts"; // @ts-expect-error .mjs runtime module import { detectRepoFromGit, getCurrentGitBranch, tryConfigurePagesToGitHubActions } from "../../runtime/git-ops.mjs"; @@ -21,11 +22,14 @@ function readFlag(args: string[], name: string): string { * overwrite). Does NOT generate docs or push (that is `pages deploy`). */ export async function runPagesActions(ctx: CommandContext): Promise { - const repo = detectRepoFromGit(ctx.cwd) as { owner: string; repo: string } | null; + let repo = detectRepoFromGit(ctx.cwd) as { owner: string; repo: string } | null; if (!repo) { - // eslint-disable-next-line no-console - console.log("\n Could not detect a GitHub repo from the git 'origin' remote.\n Add a remote or use `--push --owner --repo `.\n"); - return; + if (!interactivePromptsAvailable()) { + // eslint-disable-next-line no-console + console.log("\n Could not detect a GitHub repo from the git 'origin' remote.\n Add a remote or use `--push --owner --repo `.\n"); + return; + } + repo = await askOwnerRepo(null); } const branch = getCurrentGitBranch(ctx.cwd) as string; // eslint-disable-next-line no-console @@ -59,9 +63,14 @@ export async function runPagesDeploy(ctx: CommandContext): Promise { } } if (!owner || !repo) { - // eslint-disable-next-line no-console - console.log("\n Could not determine owner/repo. Pass `--owner --repo ` or add a git 'origin' remote.\n"); - return; + if (!interactivePromptsAvailable()) { + // eslint-disable-next-line no-console + console.log("\n Could not determine owner/repo. Pass `--owner --repo ` or add a git 'origin' remote.\n"); + return; + } + const answered = await askOwnerRepo(null, { owner, repo }); + owner = answered.owner; + repo = answered.repo; } // eslint-disable-next-line no-console @@ -76,7 +85,8 @@ export async function runPagesDeploy(ctx: CommandContext): Promise { return; } - const bin = path.join(ctx.pkgRoot, "cli", "index.mjs"); + // pkgRoot is the cli package root (cli/), where the bin lives as index.mjs. + const bin = path.join(ctx.pkgRoot, "index.mjs"); const pushArgs = ["--push", "--owner", owner, "--repo", repo, ...(docsPath ? ["--path", docsPath] : [])]; const result = spawnSync(process.execPath, [bin, ...pushArgs], { stdio: "inherit", cwd: ctx.cwd, env: process.env }); diff --git a/cli/presentation/options/resolver.ts b/cli/presentation/options/resolver.ts index c896680..cd218ff 100644 --- a/cli/presentation/options/resolver.ts +++ b/cli/presentation/options/resolver.ts @@ -5,6 +5,7 @@ import { promptConfigOnlyOptions, promptHomeOptions, promptDeployOptions, + ensureGitRepoInteractive, interactivePromptsAvailable, } from "../ui/prompts"; import { DEFAULTS } from "./schema"; @@ -19,12 +20,18 @@ export async function resolveOptions(argv: string[], env: NodeJS.ProcessEnv): Pr parsed.basePath = parsed.basePath ?? parsed.docsPath ?? DEFAULTS.home.basePath; } - // `deploy` / `--push` need owner + repo. If absent, prompt interactively - // (pre-filled from the git origin remote) instead of throwing. In CI/non-TTY - // we fall through so the explicit "requires owner and repo" guard still fires. - if (parsed.shouldPush && (!parsed.githubOwner || !parsed.githubRepo) && interactivePromptsAvailable()) { - const detected = detectRepoFromGit(process.cwd()) as { owner: string; repo: string } | null; - return promptDeployOptions(parsed, detected); + // `deploy` / `--push` need owner + repo AND a git repo. If anything is + // missing, prompt interactively (owner/repo pre-filled from the git origin + // remote; offer to `git init`) instead of throwing. In CI/non-TTY we fall + // through so the explicit guards in the push flow still fire clearly. + if (parsed.shouldPush && interactivePromptsAvailable()) { + let resolved = parsed; + if (!parsed.githubOwner || !parsed.githubRepo) { + const detected = detectRepoFromGit(process.cwd()) as { owner: string; repo: string } | null; + resolved = await promptDeployOptions(parsed, detected); + } + await ensureGitRepoInteractive(process.cwd()); + return resolved; } const runHomeInteractive = parsed.isInteractive || (shouldRunInteractive(argv) && parsed.mode === "home"); diff --git a/cli/presentation/ui/prompts.ts b/cli/presentation/ui/prompts.ts index 3f5c0d6..e63cbdb 100644 --- a/cli/presentation/ui/prompts.ts +++ b/cli/presentation/ui/prompts.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import path from "node:path"; +import { execSync } from "node:child_process"; import type { CliOptions } from "../../domain/models/cli-options"; import { DEFAULTS } from "../options/schema"; import { askText, askConfirm, note } from "./clack"; @@ -51,6 +54,23 @@ export async function promptHomeOptions(parsed: CliOptions): Promise * them (pre-filled from the git `origin` remote when available) instead of * crashing with "`--push` requires owner and repo". */ +export async function askOwnerRepo( + detected: { owner: string; repo: string } | null, + defaults?: { owner?: string; repo?: string }, +): Promise<{ owner: string; repo: string }> { + const owner = await askText({ + message: "GitHub owner (user or organization):", + defaultValue: defaults?.owner || detected?.owner || "", + validate: (v) => (v && v.trim() ? undefined : "Owner is required."), + }); + const repo = await askText({ + message: "GitHub repository name:", + defaultValue: defaults?.repo || detected?.repo || "", + validate: (v) => (v && v.trim() ? undefined : "Repository is required."), + }); + return { owner: owner.trim(), repo: repo.trim() }; +} + export async function promptDeployOptions( parsed: CliOptions, detected: { owner: string; repo: string } | null, @@ -59,24 +79,42 @@ export async function promptDeployOptions( "Deploy publishes gitpagedocs to GitHub Pages and needs the target\nrepository (owner/repo). Press Enter to accept a detected value.", "GitHub Pages deploy", ); - const githubOwner = await askText({ - message: "GitHub owner (user or organization):", - defaultValue: parsed.githubOwner || detected?.owner || "", - validate: (v) => (v && v.trim() ? undefined : "Owner is required to deploy."), - }); - const githubRepo = await askText({ - message: "GitHub repository name:", - defaultValue: parsed.githubRepo || detected?.repo || "", - validate: (v) => (v && v.trim() ? undefined : "Repository is required to deploy."), + const { owner, repo } = await askOwnerRepo(detected, { + owner: parsed.githubOwner, + repo: parsed.githubRepo, }); return { ...parsed, - githubOwner: githubOwner.trim(), - githubRepo: githubRepo.trim(), + githubOwner: owner, + githubRepo: repo, }; } +/** + * Deploy/--push needs a git repository in the current folder. When there isn't + * one, offer to run `git init` instead of crashing with "not a git repository". + * In CI/non-TTY this is a no-op so the downstream guard still reports clearly. + */ +export async function ensureGitRepoInteractive(root: string): Promise { + if (existsSync(path.join(root, ".git"))) return; + if (!interactivePromptsAvailable()) return; + const init = await askConfirm( + "This folder is not a git repository yet. Initialize one here (git init)?", + true, + ); + if (!init) { + note("Deploy needs a git repository. Run `git init`, then retry.", "Heads up"); + return; + } + try { + execSync("git init", { cwd: root, stdio: "ignore" }); + note("Initialized an empty git repository.", "git"); + } catch { + note("Could not run `git init`. Initialize git manually, then retry.", "git"); + } +} + export async function promptConfigOnlyOptions(parsed: CliOptions): Promise { const useLocalLayoutConfig = await askConfirm( "Generate local layout templates?", From 6e7a5b8323f3b7c05abb151a10714b40d88bc6ea Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 22:50:06 -0300 Subject: [PATCH 11/21] feat(tools): shared doc-access key scheme (double-hash, runtime-agnostic) Add deriveDocAccessKeys + verifyDocAccess in crypto/doc-access.ts: privateKey = sha256(password), publicKey = sha256(privateKey). verifyDocAccess unlocks with the password (double hash) or the private key (single hash). Pure, depends only on a { sha256 } service, with a local constant-time hexEqual (no node:crypto) so it is safe to bundle for the browser. Re-exported from crypto/index.ts (node) and crypto/web.ts (browser). --- tools/src/crypto/doc-access.ts | 62 ++++++++++++++++++++++++++++++++++ tools/src/crypto/index.ts | 2 ++ tools/src/crypto/web.ts | 2 ++ 3 files changed, 66 insertions(+) create mode 100644 tools/src/crypto/doc-access.ts diff --git a/tools/src/crypto/doc-access.ts b/tools/src/crypto/doc-access.ts new file mode 100644 index 0000000..dd4d580 --- /dev/null +++ b/tools/src/crypto/doc-access.ts @@ -0,0 +1,62 @@ +/** + * Documentation password-gate key scheme (runtime-agnostic). + * + * Double-hash so config.json can ship a non-reversible verifier while the user + * keeps a copyable credential: + * privateKey = sha256(password) // printed by the CLI, shareable + * publicKey = sha256(privateKey) // stored in config.json + * Unlock succeeds when the supplied input is the password OR the private key. + * + * Pure: depends only on a { sha256 } service, so the SAME code runs in the CLI + * (NodeCryptoService) and the browser (WebCryptoService). It must NOT import + * node:crypto (e.g. safeHexEqual) so it stays safe to bundle for the web. + */ + +/** Minimal hashing surface — satisfied by both Node and Web CryptoService. */ +export interface Sha256Service { + sha256(input: string): Promise; +} + +export interface DocAccessKeys { + /** sha256(password) — printed for the user to copy/share. */ + readonly privateKey: string; + /** sha256(privateKey) — safe to commit in config.json. */ + readonly publicKey: string; +} + +/** Derive the { privateKey, publicKey } pair from a plaintext password. */ +export async function deriveDocAccessKeys( + password: string, + crypto: Sha256Service, +): Promise { + const privateKey = await crypto.sha256(password); + const publicKey = await crypto.sha256(privateKey); + return { privateKey, publicKey }; +} + +/** + * Verify a user-supplied credential (password OR private key) against the stored + * public key. Returns false when either side is empty. + */ +export async function verifyDocAccess( + input: string, + publicKey: string, + crypto: Sha256Service, +): Promise { + if (!input || !publicKey) return false; + const once = await crypto.sha256(input); + if (hexEqual(once, publicKey)) return true; // input is the private key + const twice = await crypto.sha256(once); + return hexEqual(twice, publicKey); // input is the password +} + +/** + * Length-checked, constant-time hex comparison. Local (not safeHexEqual) so this + * module never imports node:crypto and stays browser-bundle-safe. + */ +function hexEqual(a: string, b: string): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i += 1) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); + return diff === 0; +} diff --git a/tools/src/crypto/index.ts b/tools/src/crypto/index.ts index a540bbc..be2a3bf 100644 --- a/tools/src/crypto/index.ts +++ b/tools/src/crypto/index.ts @@ -1,2 +1,4 @@ export { NodeCryptoService, safeHexEqual } from "./node-crypto-service"; export { WebCryptoService } from "./web-crypto-service"; +export { deriveDocAccessKeys, verifyDocAccess } from "./doc-access"; +export type { DocAccessKeys, Sha256Service } from "./doc-access"; diff --git a/tools/src/crypto/web.ts b/tools/src/crypto/web.ts index 9a13ef3..316f79f 100644 --- a/tools/src/crypto/web.ts +++ b/tools/src/crypto/web.ts @@ -1,2 +1,4 @@ // Browser-safe crypto entry: Web Crypto only (no node:crypto). export { WebCryptoService } from "./web-crypto-service"; +export { deriveDocAccessKeys, verifyDocAccess } from "./doc-access"; +export type { DocAccessKeys, Sha256Service } from "./doc-access"; From e75c22e372b28c31b1678ed75e93ce58c058d7e4 Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 22:50:36 -0300 Subject: [PATCH 12/21] feat(cli): `gitpagedocs password` command + docs-access config generation - New interactive `password` command (run-command REGISTRY): type + confirm a password (masked via a new askPassword clack wrapper), derive the double-hash keys, write the PUBLIC key into gitpagedocs/config.json via a new GitPageDocsConfigRepository (raw-JSON patch that preserves every other key), and print the PRIVATE key to copy. Guards non-TTY so it never hangs. - Generate the gate defaults: site.docsAccess = { enabled:false, publicKey:"" }, an IconDocsLock* icon block (FiLock), and the full gate/lock/block-popup + previously-inline aiChat* langmenu keys in pt/en/es. repositorySearchHome stays false. --- cli/builders/root-config-builder.mjs | 1 + cli/data/default-site-config.mjs | 9 + cli/data/i18n-langmenu.mjs | 183 ++++++++++++++++++ cli/infrastructure/gitpagedocs-config-file.ts | 47 +++++ cli/presentation/commands/password.ts | 62 ++++++ cli/presentation/commands/run-command.ts | 2 + cli/presentation/ui/clack.ts | 17 ++ 7 files changed, 321 insertions(+) create mode 100644 cli/infrastructure/gitpagedocs-config-file.ts create mode 100644 cli/presentation/commands/password.ts diff --git a/cli/builders/root-config-builder.mjs b/cli/builders/root-config-builder.mjs index 1676454..cfb49bd 100644 --- a/cli/builders/root-config-builder.mjs +++ b/cli/builders/root-config-builder.mjs @@ -44,6 +44,7 @@ export function buildRootConfig(options = {}) { repositorySearchHome, rendering: renderingUrl, AiChatEnabled: true, + docsAccess: { enabled: false, publicKey: "" }, langmenu: defaultLangMenu, }, VersionControl: { diff --git a/cli/data/default-site-config.mjs b/cli/data/default-site-config.mjs index 4127d55..c5a67a2 100644 --- a/cli/data/default-site-config.mjs +++ b/cli/data/default-site-config.mjs @@ -114,6 +114,15 @@ export function getDefaultSiteConfig(DOCS, projectLink) { IconNavMenuBlockInactiveReactIconesTagSize: "25px", IconNavMenuBlockInactiveImgWidth: 20, IconNavMenuBlockInactiveImgHeight: 20, + IconDocsLockLightImg: "", + IconDocsLockDarkImg: "", + IconDocsLockReactIcones: true, + IconDocsLockReactIconesTag: "FiLock", + IconDocsLockReactIconesTagColorDark: "White", + IconDocsLockReactIconesTagColorLight: "black", + IconDocsLockReactIconesTagSize: "22px", + IconDocsLockImgWidth: 20, + IconDocsLockImgHeight: 20, RouteguideBrandPositionDefault: "center", RouteguideBrandContainerTopDefault: false, audioPlayerEnabled: true, diff --git a/cli/data/i18n-langmenu.mjs b/cli/data/i18n-langmenu.mjs index 6ca3b27..1278a9e 100644 --- a/cli/data/i18n-langmenu.mjs +++ b/cli/data/i18n-langmenu.mjs @@ -38,6 +38,67 @@ export const defaultLangMenu = { audioPopoverStatusPaused: "Pausado", audioPopoverStatusLoopOn: "Loop ativado", audioPopoverStatusLoopOff: "Loop desativado", + aiChatTitle: "Assistente de IA", + aiChatPlaceholder: "Pergunte sobre a documentação...", + aiChatConfigTitle: "Configurar IA", + aiChatConfigDesc: "Insira sua chave de API para ativar o chat.", + aiChatClearDataBtn: "Limpar dados locais", + aiChatSendBtn: "Enviar", + aiChatSaveStartBtn: "Salvar e iniciar conversa", + aiChatAttachAriaLabel: "Anexar arquivo de áudio ou imagem", + aiChatAttachedFilesLabel: "arquivo(s) anexado(s)", + aiChatRemoveAttachmentBtn: "Remover", + aiChatCancelResponseLabel: "Cancelar resposta", + aiChatOpenBtnAriaLabel: "Abrir chat de IA", + aiChatCloseBtnAriaLabel: "Fechar chat", + aiChatSystemPrompt: "You are the official AI documentation assistant for the '{headerName}' project. Your primary role is to answer questions strictly related to the project's documentation, configurations (like config.json), and markdown files located in the 'gitpagedocs' directory. Be highly professional and helpful.\n\nIMPORTANT: You MUST address the user and respond EXCLUSIVELY in the following language: {language}.\n\nHere is the raw content of the active page the user is currently looking at:\n[Page ID]: {pageId}\n[Hidden Current Context Content]:\n{rawContent}", + aiChatEmptyStateGreeting: "Olá! Eu sou o assistente de inteligência artificial desta documentação. Você pode me fazer perguntas sobre o conteúdo ou buscar um termo em toda a documentação. Como posso ajudar você hoje?", + aiChatDisclaimer: "A IA pode cometer erros. Sempre verifique as informações.", + aiChatPasswordCreateDesc: "Crie uma senha local para criptografar suas chaves de API.", + aiChatPasswordUnlockDesc: "Digite sua senha local para desbloquear.", + aiChatPasswordPlaceholder: "Senha local", + aiChatCreatePasswordBtn: "Criar senha", + aiChatUnlockBtn: "Desbloquear", + aiChatWrongPassword: "Senha incorreta.", + aiChatLockBtn: "Bloquear", + aiChatResetBtn: "Esqueceu a senha?", + aiChatResetPopupTitle: "Redefinir senha?", + aiChatResetPopupDesc: "Isso apaga todas as chaves de API salvas e a senha local. Você criará uma nova senha na próxima vez.", + aiChatResetConfirmBtn: "Sim, redefinir", + aiChatResetCancelBtn: "Cancelar", + aiChatProviderLabel: "Provedor:", + aiChatApiKeyLabel: "Chave de API (deixe em branco para IA local):", + aiChatProviderOpenAI: "OpenAI (GPT-4o-mini)", + aiChatProviderClaude: "Anthropic Claude (3.5 Sonnet)", + aiChatProviderGemini: "Google Gemini (1.5 Flash)", + aiChatProviderOllama: "Ollama Network (Local LLMs)", + aiChatEmptyPageContent: "Nenhuma página ativa compatível.", + aiChatNoPageId: "Sem página", + aiChatOllamaUrlLabel: "URL da API Ollama (deixe em branco para local)", + aiChatClearChatPopupTitle: "Limpar conversa?", + aiChatClearChatPopupDesc: "Isso apagará todo o histórico atual do chat. Esta ação não pode ser desfeita.", + aiChatClearChatConfirmBtn: "Sim, apagar", + aiChatClearChatCancelBtn: "Cancelar", + aiChatClearDataPopupTitle: "Apagar todos os dados?", + aiChatClearDataPopupDesc: "Isso apagará todos os chats e as chaves de API locais. O chat de IA será fechado.", + aiChatClearDataConfirmBtn: "Sim, apagar tudo", + aiChatClearDataCancelBtn: "Cancelar", + aiChatUserLabel: "Você", + aiChatApiError: "Ocorreu um erro de conexão com a API.", + aiChatError401: "Chave de acesso inválida ou provedor não autorizado.", + aiChatError429: "Limite de requisições excedido. Tente novamente mais tarde.", + aiChatError500: "Ocorreu um erro interno no servidor do modelo de IA.", + aiChatErrorGeneric: "Ocorreu um erro inesperado ao se comunicar com a IA.", + docsAccessGateTitle: "Documentação protegida", + docsAccessGateDescription: "Digite a senha ou a chave privada para acessar a documentação.", + docsAccessInputPlaceholder: "Senha ou chave privada", + docsAccessUnlockBtn: "Desbloquear", + docsAccessWrongCredential: "Senha ou chave incorreta.", + docsAccessLockTooltip: "Bloquear documentação", + docsAccessBlockPopupTitle: "Bloquear a documentação?", + docsAccessBlockPopupDesc: "Você precisará da senha novamente na próxima visita.", + docsAccessBlockConfirmBtn: "Sim, bloquear", + docsAccessBlockCancelBtn: "Cancelar", }, en: { pt: "Portuguese", @@ -78,6 +139,67 @@ export const defaultLangMenu = { audioPopoverStatusPaused: "Paused", audioPopoverStatusLoopOn: "Loop on", audioPopoverStatusLoopOff: "Loop off", + aiChatTitle: "AI Assistant", + aiChatPlaceholder: "Ask about the documentation...", + aiChatConfigTitle: "Configure AI", + aiChatConfigDesc: "Enter your API key to enable the chat.", + aiChatClearDataBtn: "Clear local data", + aiChatSendBtn: "Send", + aiChatSaveStartBtn: "Save & Start Chatting", + aiChatAttachAriaLabel: "Attach audio or image file", + aiChatAttachedFilesLabel: "file(s) attached", + aiChatRemoveAttachmentBtn: "Remove", + aiChatCancelResponseLabel: "Cancel response", + aiChatOpenBtnAriaLabel: "Open AI Chat", + aiChatCloseBtnAriaLabel: "Close chat", + aiChatSystemPrompt: "You are the official AI documentation assistant for the '{headerName}' project. Your primary role is to answer questions strictly related to the project's documentation, configurations (like config.json), and markdown files located in the 'gitpagedocs' directory. Be highly professional and helpful.\n\nIMPORTANT: You MUST address the user and respond EXCLUSIVELY in the following language: {language}.\n\nHere is the raw content of the active page the user is currently looking at:\n[Page ID]: {pageId}\n[Hidden Current Context Content]:\n{rawContent}", + aiChatEmptyStateGreeting: "Hello! I am the artificial intelligence assistant of this documentation. You can ask me questions about the content or search for a term across the documentation. How can I help you today?", + aiChatDisclaimer: "AI can make mistakes. Always verify the information.", + aiChatPasswordCreateDesc: "Create a local password to encrypt your API keys.", + aiChatPasswordUnlockDesc: "Enter your local password to unlock.", + aiChatPasswordPlaceholder: "Local password", + aiChatCreatePasswordBtn: "Create password", + aiChatUnlockBtn: "Unlock", + aiChatWrongPassword: "Incorrect password.", + aiChatLockBtn: "Lock", + aiChatResetBtn: "Forgot password?", + aiChatResetPopupTitle: "Reset password?", + aiChatResetPopupDesc: "This erases all saved API keys and the local password. You'll create a new password next time.", + aiChatResetConfirmBtn: "Yes, reset", + aiChatResetCancelBtn: "Cancel", + aiChatProviderLabel: "Provider:", + aiChatApiKeyLabel: "API Key (leave blank for Local AI):", + aiChatProviderOpenAI: "OpenAI (GPT-4o-mini)", + aiChatProviderClaude: "Anthropic Claude (3.5 Sonnet)", + aiChatProviderGemini: "Google Gemini (1.5 Flash)", + aiChatProviderOllama: "Ollama Network (Local LLMs)", + aiChatEmptyPageContent: "No compatible active page.", + aiChatNoPageId: "No page", + aiChatOllamaUrlLabel: "Ollama API URL (leave blank for local)", + aiChatClearChatPopupTitle: "Clear conversation?", + aiChatClearChatPopupDesc: "This will delete all current chat history. This action cannot be undone.", + aiChatClearChatConfirmBtn: "Yes, delete", + aiChatClearChatCancelBtn: "Cancel", + aiChatClearDataPopupTitle: "Erase all data?", + aiChatClearDataPopupDesc: "This will delete all chats and local API keys. The AI chat will be closed.", + aiChatClearDataConfirmBtn: "Yes, erase everything", + aiChatClearDataCancelBtn: "Cancel", + aiChatUserLabel: "You", + aiChatApiError: "An API connection error occurred.", + aiChatError401: "Invalid access key or unauthorized provider.", + aiChatError429: "Rate limit exceeded. Please try again later.", + aiChatError500: "An internal server error occurred on the AI model.", + aiChatErrorGeneric: "An unexpected error occurred while communicating with the AI.", + docsAccessGateTitle: "Protected documentation", + docsAccessGateDescription: "Enter the password or private key to view the documentation.", + docsAccessInputPlaceholder: "Password or private key", + docsAccessUnlockBtn: "Unlock", + docsAccessWrongCredential: "Incorrect password or key.", + docsAccessLockTooltip: "Lock documentation", + docsAccessBlockPopupTitle: "Block the documentation?", + docsAccessBlockPopupDesc: "You'll need the password again on your next visit.", + docsAccessBlockConfirmBtn: "Yes, block", + docsAccessBlockCancelBtn: "Cancel", }, es: { pt: "Portugues", @@ -118,5 +240,66 @@ export const defaultLangMenu = { audioPauseLabel: "Pausar música de fondo", audioPlaylistTitle: "Elegir pista", audioPlaylistDescription: "Seleccione una pista para reproducir de la playlist.", + aiChatTitle: "Asistente de IA", + aiChatPlaceholder: "Pregunta sobre la documentación...", + aiChatConfigTitle: "Configurar IA", + aiChatConfigDesc: "Introduce tu clave de API para activar el chat.", + aiChatClearDataBtn: "Borrar datos locales", + aiChatSendBtn: "Enviar", + aiChatSaveStartBtn: "Guardar y empezar a chatear", + aiChatAttachAriaLabel: "Adjuntar archivo de audio o imagen", + aiChatAttachedFilesLabel: "archivo(s) adjunto(s)", + aiChatRemoveAttachmentBtn: "Quitar", + aiChatCancelResponseLabel: "Cancelar respuesta", + aiChatOpenBtnAriaLabel: "Abrir chat de IA", + aiChatCloseBtnAriaLabel: "Cerrar chat", + aiChatSystemPrompt: "You are the official AI documentation assistant for the '{headerName}' project. Your primary role is to answer questions strictly related to the project's documentation, configurations (like config.json), and markdown files located in the 'gitpagedocs' directory. Be highly professional and helpful.\n\nIMPORTANT: You MUST address the user and respond EXCLUSIVELY in the following language: {language}.\n\nHere is the raw content of the active page the user is currently looking at:\n[Page ID]: {pageId}\n[Hidden Current Context Content]:\n{rawContent}", + aiChatEmptyStateGreeting: "¡Hola! Soy el asistente de inteligencia artificial de esta documentación. Puedes hacerme preguntas sobre el contenido o buscar un término en toda la documentación. ¿Cómo puedo ayudarte hoy?", + aiChatDisclaimer: "La IA puede cometer errores. Verifica siempre la información.", + aiChatPasswordCreateDesc: "Crea una contraseña local para cifrar tus claves de API.", + aiChatPasswordUnlockDesc: "Introduce tu contraseña local para desbloquear.", + aiChatPasswordPlaceholder: "Contraseña local", + aiChatCreatePasswordBtn: "Crear contraseña", + aiChatUnlockBtn: "Desbloquear", + aiChatWrongPassword: "Contraseña incorrecta.", + aiChatLockBtn: "Bloquear", + aiChatResetBtn: "¿Olvidaste la contraseña?", + aiChatResetPopupTitle: "¿Restablecer contraseña?", + aiChatResetPopupDesc: "Esto borra todas las claves de API guardadas y la contraseña local. Crearás una nueva contraseña la próxima vez.", + aiChatResetConfirmBtn: "Sí, restablecer", + aiChatResetCancelBtn: "Cancelar", + aiChatProviderLabel: "Proveedor:", + aiChatApiKeyLabel: "Clave de API (déjalo en blanco para IA local):", + aiChatProviderOpenAI: "OpenAI (GPT-4o-mini)", + aiChatProviderClaude: "Anthropic Claude (3.5 Sonnet)", + aiChatProviderGemini: "Google Gemini (1.5 Flash)", + aiChatProviderOllama: "Ollama Network (Local LLMs)", + aiChatEmptyPageContent: "Ninguna página activa compatible.", + aiChatNoPageId: "Sin página", + aiChatOllamaUrlLabel: "URL de la API de Ollama (déjalo en blanco para local)", + aiChatClearChatPopupTitle: "¿Borrar conversación?", + aiChatClearChatPopupDesc: "Esto eliminará todo el historial de chat actual. Esta acción no se puede deshacer.", + aiChatClearChatConfirmBtn: "Sí, borrar", + aiChatClearChatCancelBtn: "Cancelar", + aiChatClearDataPopupTitle: "¿Borrar todos los datos?", + aiChatClearDataPopupDesc: "Esto eliminará todos los chats y las claves de API locales. El chat de IA se cerrará.", + aiChatClearDataConfirmBtn: "Sí, borrar todo", + aiChatClearDataCancelBtn: "Cancelar", + aiChatUserLabel: "Tú", + aiChatApiError: "Se produjo un error de conexión con la API.", + aiChatError401: "Clave de acceso no válida o proveedor no autorizado.", + aiChatError429: "Límite de solicitudes superado. Inténtalo de nuevo más tarde.", + aiChatError500: "Se produjo un error interno del servidor en el modelo de IA.", + aiChatErrorGeneric: "Se produjo un error inesperado al comunicarse con la IA.", + docsAccessGateTitle: "Documentación protegida", + docsAccessGateDescription: "Introduce la contraseña o la clave privada para ver la documentación.", + docsAccessInputPlaceholder: "Contraseña o clave privada", + docsAccessUnlockBtn: "Desbloquear", + docsAccessWrongCredential: "Contraseña o clave incorrecta.", + docsAccessLockTooltip: "Bloquear documentación", + docsAccessBlockPopupTitle: "¿Bloquear la documentación?", + docsAccessBlockPopupDesc: "Tendrás que introducir la contraseña de nuevo en la próxima visita.", + docsAccessBlockConfirmBtn: "Sí, bloquear", + docsAccessBlockCancelBtn: "Cancelar", }, }; diff --git a/cli/infrastructure/gitpagedocs-config-file.ts b/cli/infrastructure/gitpagedocs-config-file.ts new file mode 100644 index 0000000..6815f5b --- /dev/null +++ b/cli/infrastructure/gitpagedocs-config-file.ts @@ -0,0 +1,47 @@ +import fs from "node:fs/promises"; +import { defaultConfigLoader } from "@gitpagedocs/tools"; + +export interface DocsAccessConfig { + enabled: boolean; + publicKey: string; +} + +/** + * Reads and patches the generated gitpagedocs/config.json IN PLACE, preserving + * every existing key (raw-JSON round-trip — it never reserializes through a typed + * model that could drop unknown fields). Used by interactive commands that need + * to persist a single value (e.g. the documentation password public key). + */ +export class GitPageDocsConfigRepository { + constructor(private readonly cwd: string = process.cwd()) {} + + /** Absolute path to the JSON config, or throws a friendly, actionable error. */ + async resolvePath(): Promise { + const resolved = await defaultConfigLoader.resolveConfigPath(this.cwd); + if (!resolved) { + throw new Error("gitpagedocs config.json not found. Run `gitpagedocs` first to generate it."); + } + if (!resolved.endsWith(".json")) { + throw new Error(`Expected a JSON config to patch, found "${resolved}". Use a gitpagedocs/config.json.`); + } + return resolved; + } + + /** + * Set `site.docsAccess = { enabled, publicKey }`, leaving all other config keys + * untouched. Returns the path that was written. + */ + async patchDocsAccess(publicKey: string, enabled = true): Promise { + const configPath = await this.resolvePath(); + const raw = await fs.readFile(configPath, "utf-8"); + const config = JSON.parse(raw) as Record; + const site = + config.site && typeof config.site === "object" + ? (config.site as Record) + : {}; + site.docsAccess = { enabled, publicKey } satisfies DocsAccessConfig; + config.site = site; + await fs.writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf-8"); + return configPath; + } +} diff --git a/cli/presentation/commands/password.ts b/cli/presentation/commands/password.ts new file mode 100644 index 0000000..367978a --- /dev/null +++ b/cli/presentation/commands/password.ts @@ -0,0 +1,62 @@ +import { NodeCryptoService, deriveDocAccessKeys } from "@gitpagedocs/tools"; +import type { CommandContext } from "./run-command"; +import { askPassword, note, outro } from "../ui/clack"; +import { interactivePromptsAvailable } from "../ui/prompts"; +import { GitPageDocsConfigRepository } from "../../infrastructure/gitpagedocs-config-file"; + +const MIN_PASSWORD_LENGTH = 4; + +/** + * `gitpagedocs password` — interactively set a documentation access password. + * + * Type + confirm a password, derive the double-hash key pair, write the PUBLIC + * key into gitpagedocs/config.json (which makes the frontend gate the docs), and + * print the PRIVATE key for the user to copy. The password itself is never stored. + */ +export async function runPassword(ctx: CommandContext): Promise { + if (!interactivePromptsAvailable()) { + // eslint-disable-next-line no-console + console.log("\n `gitpagedocs password` is interactive — run it in a terminal (TTY).\n"); + return; + } + + const password = await askPassword({ + message: "Documentation password:", + validate: (v) => + v && v.trim().length >= MIN_PASSWORD_LENGTH + ? undefined + : `Use at least ${MIN_PASSWORD_LENGTH} characters.`, + }); + const confirmation = await askPassword({ + message: "Confirm password:", + validate: (v) => (v ? undefined : "Required."), + }); + + if (password !== confirmation) { + note("Passwords do not match. Nothing was changed.", "Aborted"); + return; + } + + const { privateKey, publicKey } = await deriveDocAccessKeys(password, new NodeCryptoService()); + + let configPath: string; + try { + configPath = await new GitPageDocsConfigRepository(ctx.cwd).patchDocsAccess(publicKey); + } catch (error) { + note(error instanceof Error ? error.message : String(error), "Could not save"); + return; + } + + note( + `Public key saved to ${configPath}\nThe documentation is now password-protected.`, + "Saved", + ); + // eslint-disable-next-line no-console + console.log( + "\n PRIVATE KEY (copy & keep it safe — it also unlocks the docs):\n\n" + + ` ${privateKey}\n\n` + + " Readers can unlock with the password OR this private key.\n" + + " To remove protection, set site.docsAccess.enabled to false in config.json.\n", + ); + outro("Done."); +} diff --git a/cli/presentation/commands/run-command.ts b/cli/presentation/commands/run-command.ts index 1858eb2..fce3a92 100644 --- a/cli/presentation/commands/run-command.ts +++ b/cli/presentation/commands/run-command.ts @@ -21,6 +21,7 @@ import { runConfig } from "./config-info"; import { runMcp } from "./mcp"; import { runDocs } from "./docs"; import { runPagesActions, runPagesDeploy } from "./pages"; +import { runPassword } from "./password"; const REGISTRY: Record = { version: runVersion, @@ -34,6 +35,7 @@ const REGISTRY: Record = { config: runConfig, mcp: runMcp, docs: runDocs, + password: runPassword, }; /** Returns true if a new command handled the invocation (skip legacy flow). */ diff --git a/cli/presentation/ui/clack.ts b/cli/presentation/ui/clack.ts index 8e0ed93..4540286 100644 --- a/cli/presentation/ui/clack.ts +++ b/cli/presentation/ui/clack.ts @@ -5,6 +5,7 @@ */ import { text, + password, confirm, select, multiselect, @@ -40,6 +41,22 @@ export async function askText(options: AskTextOptions): Promise { return exitIfCancelled(result); } +export interface AskPasswordOptions { + message: string; + validate?: (value: string) => string | undefined; +} + +/** Masked password prompt (input hidden as the user types). */ +export async function askPassword(options: AskPasswordOptions): Promise { + const result = await password({ + message: options.message, + validate: options.validate + ? (value) => options.validate?.(value ?? "") + : undefined, + }); + return exitIfCancelled(result); +} + export async function askConfirm(message: string, initialValue = false): Promise { const result = await confirm({ message, initialValue }); return exitIfCancelled(result); From 29d4cf1413a4cfe5d48cce7611bf4d17ebe13df9 Mon Sep 17 00:00:00 2001 From: Kauan Vidigal Date: Sun, 14 Jun 2026 22:50:52 -0300 Subject: [PATCH 13/21] feat(frontend): documentation-wide password gate + chatbot i18n from config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New features/docs-access slice: useDocsAccess state machine (loading/locked/ unlocked) backed by localStorage (caches only the public hash, re-verified against config on load), a full-page DocsAccessGate, and a DocsLockButton that re-locks via the shared ConfirmPopup. Unlock accepts the password OR the private key via @gitpagedocs/tools/crypto/web verifyDocAccess. - docs-shell renders the gate (after all hooks) when locked, and a sidebar lock button when enabled; backward compatible (disabled/empty publicKey ⇒ docs open). - Reuse the nav-menu icon resolver factory for the docsLock icon; add docsAccess + IconDocsLock* to the SiteConfig type and the new labels to use-docs-shell-labels. - Move remaining hardcoded chat strings ("arquivo(s) anexado(s)", "Remover", "Cancelar Resposta", Save & Start button) to config-driven labels (en/pt/es). --- .../src/entities/docs/model/types/site.ts | 17 ++++ .../src/features/ask-ai/ui/api-key-form.tsx | 2 +- frontend/src/features/docs-access/index.ts | 10 ++ .../docs-access/model/use-docs-access.ts | 96 ++++++++++++++++++ .../ui/docs-access-gate.module.css | 98 +++++++++++++++++++ .../docs-access/ui/docs-access-gate.tsx | 58 +++++++++++ .../docs-access/ui/docs-lock-button.tsx | 66 +++++++++++++ .../nav-menu/resolve-nav-menu-icon-factory.ts | 21 ++++ .../icons/nav-menu/resolve-nav-menu-icon.ts | 9 ++ .../ai-chat-drawer/ui/ai-chat-drawer.tsx | 6 +- .../src/widgets/docs-shell/docs-shell.tsx | 43 ++++++++ .../docs-shell/model/use-docs-shell-labels.ts | 61 ++++++++++++ .../docs-shell/ui/docs-shell-sidebar.tsx | 16 +++ 13 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 frontend/src/features/docs-access/index.ts create mode 100644 frontend/src/features/docs-access/model/use-docs-access.ts create mode 100644 frontend/src/features/docs-access/ui/docs-access-gate.module.css create mode 100644 frontend/src/features/docs-access/ui/docs-access-gate.tsx create mode 100644 frontend/src/features/docs-access/ui/docs-lock-button.tsx diff --git a/frontend/src/entities/docs/model/types/site.ts b/frontend/src/entities/docs/model/types/site.ts index 29b165d..d5e0266 100644 --- a/frontend/src/entities/docs/model/types/site.ts +++ b/frontend/src/entities/docs/model/types/site.ts @@ -135,6 +135,16 @@ export interface SiteConfig { IconSidebarExpandReactIconesTagSize?: string; IconSidebarExpandImgWidth?: string | number; IconSidebarExpandImgHeight?: string | number; + /** Documentation lock button (clears the access cache to re-block the docs) */ + IconDocsLockLightImg?: string; + IconDocsLockDarkImg?: string; + IconDocsLockReactIcones?: boolean; + IconDocsLockReactIconesTag?: string; + IconDocsLockReactIconesTagColorDark?: string; + IconDocsLockReactIconesTagColorLight?: string; + IconDocsLockReactIconesTagSize?: string; + IconDocsLockImgWidth?: string | number; + IconDocsLockImgHeight?: string | number; /** Block menu on nav toggle: active (blocking) state icon */ IconNavMenuBlockActiveLightImg?: string; IconNavMenuBlockActiveDarkImg?: string; @@ -256,6 +266,13 @@ export interface SiteConfig { /** AI Chat toggle: enable/disable entirely */ AiChatEnabled?: boolean; + /** Documentation-wide password gate. When enabled with a publicKey, the + * frontend blocks all docs until the visitor enters the password or private key. */ + docsAccess?: { + enabled?: boolean; + publicKey?: string; + }; + /** AI Chat toggle: open icon */ IconAiChatOpenLightImg?: string; IconAiChatOpenDarkImg?: string; diff --git a/frontend/src/features/ask-ai/ui/api-key-form.tsx b/frontend/src/features/ask-ai/ui/api-key-form.tsx index b689571..8b7a72e 100644 --- a/frontend/src/features/ask-ai/ui/api-key-form.tsx +++ b/frontend/src/features/ask-ai/ui/api-key-form.tsx @@ -80,7 +80,7 @@ export const ApiKeyForm: React.FC = ({ onSave, labels }) => { data-testid="drawer-save-key" className={styles.btnPrimary} > - {labels?.aiChatSendBtn || "Save & Start Chatting"} + {labels?.aiChatSaveStartBtn || "Save & Start Chatting"} diff --git a/frontend/src/features/docs-access/index.ts b/frontend/src/features/docs-access/index.ts new file mode 100644 index 0000000..cb4ebdd --- /dev/null +++ b/frontend/src/features/docs-access/index.ts @@ -0,0 +1,10 @@ +export { useDocsAccess } from "./model/use-docs-access"; +export type { + DocsAccessState, + DocsAccessConfigInput, + UseDocsAccessResult, +} from "./model/use-docs-access"; +export { DocsAccessGate } from "./ui/docs-access-gate"; +export type { DocsAccessTexts } from "./ui/docs-access-gate"; +export { DocsLockButton } from "./ui/docs-lock-button"; +export type { DocsLockTexts } from "./ui/docs-lock-button"; diff --git a/frontend/src/features/docs-access/model/use-docs-access.ts b/frontend/src/features/docs-access/model/use-docs-access.ts new file mode 100644 index 0000000..bc1f6df --- /dev/null +++ b/frontend/src/features/docs-access/model/use-docs-access.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useState } from "react"; +import { WebCryptoService, verifyDocAccess } from "@gitpagedocs/tools/crypto/web"; + +export type DocsAccessState = "loading" | "locked" | "unlocked"; + +export interface DocsAccessConfigInput { + enabled?: boolean; + publicKey?: string; +} + +export interface UseDocsAccessResult { + /** Whether the gate is enabled at all (config has enabled + publicKey). */ + enabled: boolean; + state: DocsAccessState; + /** Verify a password OR private key; persists + unlocks on success. */ + unlock: (input: string) => Promise; + /** Clear the cached unlock so the next visit re-blocks. */ + lockAgain: () => void; +} + +/** Same convention as use-docs-preferences buildStorageKey, scoped to this site. */ +function buildStorageKey(siteName: string): string { + return `git-page-docs:docs-access:${siteName.toLowerCase().replaceAll(" ", "-")}`; +} + +function readCache(storageKey: string): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(storageKey); + } catch { + return null; + } +} + +function writeCache(storageKey: string, value: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey, value); + } catch { + // Ignore storage failures (private mode, blocked storage). + } +} + +function clearCache(storageKey: string): void { + if (typeof window === "undefined") return; + try { + window.localStorage.removeItem(storageKey); + } catch { + // Ignore storage failures. + } +} + +/** + * Documentation-wide access gate state. Backward compatible: when the gate is + * disabled or no public key is configured, the state is "unlocked" (docs open). + * The cache stores ONLY the public hash; the password/private key is never + * persisted, and a changed config public key invalidates a stale cache. + */ +export function useDocsAccess( + config: DocsAccessConfigInput | undefined, + siteName: string, +): UseDocsAccessResult { + const enabled = Boolean(config?.enabled); + const publicKey = (config?.publicKey ?? "").trim(); + const gateActive = enabled && publicKey.length > 0; + const storageKey = buildStorageKey(siteName); + const [state, setState] = useState("loading"); + + useEffect(() => { + if (!gateActive) { + setState("unlocked"); + return; + } + const cached = readCache(storageKey); + setState(cached && cached === publicKey ? "unlocked" : "locked"); + }, [gateActive, publicKey, storageKey]); + + const unlock = useCallback( + async (input: string): Promise => { + if (!publicKey) return false; + const ok = await verifyDocAccess(input.trim(), publicKey, new WebCryptoService()); + if (!ok) return false; + writeCache(storageKey, publicKey); + setState("unlocked"); + return true; + }, + [publicKey, storageKey], + ); + + const lockAgain = useCallback(() => { + clearCache(storageKey); + setState("locked"); + }, [storageKey]); + + return { enabled: gateActive, state, unlock, lockAgain }; +} diff --git a/frontend/src/features/docs-access/ui/docs-access-gate.module.css b/frontend/src/features/docs-access/ui/docs-access-gate.module.css new file mode 100644 index 0000000..fe23474 --- /dev/null +++ b/frontend/src/features/docs-access/ui/docs-access-gate.module.css @@ -0,0 +1,98 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 3000; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgba(8, 10, 16, 0.92); + backdrop-filter: blur(6px); +} + +.card { + width: 100%; + max-width: 380px; + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 2rem 1.75rem; + border-radius: 16px; + background: #ffffff; + color: #111827; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45); + text-align: center; +} + +.title { + margin: 0; + font-size: 1.35rem; + font-weight: 700; +} + +.description { + margin: 0; + font-size: 0.92rem; + line-height: 1.5; + color: #4b5563; +} + +.input { + width: 100%; + padding: 0.7rem 0.85rem; + border: 1px solid #d1d5db; + border-radius: 10px; + font-size: 0.95rem; + outline: none; + box-sizing: border-box; +} + +.input:focus { + border-color: #2563eb; + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2); +} + +.error { + margin: 0; + font-size: 0.85rem; + color: #dc2626; +} + +.button { + width: 100%; + padding: 0.7rem 1rem; + border: none; + border-radius: 10px; + background: #2563eb; + color: #ffffff; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s ease, opacity 0.15s ease; +} + +.button:hover:not(:disabled) { + background: #1d4ed8; +} + +.button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +@media (prefers-color-scheme: dark) { + .card { + background: #11151c; + color: #f3f4f6; + } + + .description { + color: #9ca3af; + } + + .input { + background: #0b0e13; + border-color: #374151; + color: #f3f4f6; + } +} diff --git a/frontend/src/features/docs-access/ui/docs-access-gate.tsx b/frontend/src/features/docs-access/ui/docs-access-gate.tsx new file mode 100644 index 0000000..b4742b3 --- /dev/null +++ b/frontend/src/features/docs-access/ui/docs-access-gate.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import styles from "./docs-access-gate.module.css"; + +export interface DocsAccessTexts { + title: string; + description: string; + placeholder: string; + unlockBtn: string; + wrongCredential: string; +} + +interface DocsAccessGateProps { + texts: DocsAccessTexts; + onUnlock: (input: string) => Promise; +} + +/** Full-page gate shown when the documentation is locked behind a password. */ +export function DocsAccessGate({ texts, onUnlock }: DocsAccessGateProps) { + const [value, setValue] = useState(""); + const [error, setError] = useState(false); + const [busy, setBusy] = useState(false); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (busy || !value.trim()) return; + setBusy(true); + const ok = await onUnlock(value); + setBusy(false); + setError(!ok); + }; + + return ( +
+
+

{texts.title}

+

{texts.description}

+ { + setValue(event.target.value); + setError(false); + }} + placeholder={texts.placeholder} + autoFocus + autoComplete="off" + /> + {error &&

{texts.wrongCredential}

} + +
+
+ ); +} diff --git a/frontend/src/features/docs-access/ui/docs-lock-button.tsx b/frontend/src/features/docs-access/ui/docs-lock-button.tsx new file mode 100644 index 0000000..e9aeebd --- /dev/null +++ b/frontend/src/features/docs-access/ui/docs-lock-button.tsx @@ -0,0 +1,66 @@ +"use client"; + +import Image from "next/image"; +import { useState } from "react"; +import { ReactIconByTag } from "@/shared/ui/react-icon-by-tag"; +import { ConfirmPopup } from "@/shared/ui/confirm-popup/confirm-popup"; +import type { ResolvedNavMenuIconConfig } from "@/shared/lib/icons/nav-menu/resolve-nav-menu-icon"; + +export interface DocsLockTexts { + tooltip: string; + popupTitle: string; + popupDescription: string; + confirmText: string; + cancelText: string; +} + +interface DocsLockButtonProps { + icon: ResolvedNavMenuIconConfig; + texts: DocsLockTexts; + onConfirmBlock: () => void; + className?: string; +} + +/** Menu button that re-locks the documentation (clears the unlock cache) after + * a confirmation popup. Reuses the shared ConfirmPopup and icon resolver. */ +export function DocsLockButton({ icon, texts, onConfirmBlock, className }: DocsLockButtonProps) { + const [isPopupOpen, setIsPopupOpen] = useState(false); + + return ( + <> + + setIsPopupOpen(false)} + /> + + ); +} diff --git a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts index 26b7048..91ab06a 100644 --- a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts +++ b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon-factory.ts @@ -76,6 +76,15 @@ export interface NavMenuIconConfigInput { IconSidebarExpandReactIconesTagSize?: string; IconSidebarExpandImgWidth?: string | number; IconSidebarExpandImgHeight?: string | number; + IconDocsLockLightImg?: string; + IconDocsLockDarkImg?: string; + IconDocsLockReactIcones?: boolean; + IconDocsLockReactIconesTag?: string; + IconDocsLockReactIconesTagColorDark?: string; + IconDocsLockReactIconesTagColorLight?: string; + IconDocsLockReactIconesTagSize?: string; + IconDocsLockImgWidth?: string | number; + IconDocsLockImgHeight?: string | number; } export interface ResolvedNavMenuIconConfig { @@ -201,6 +210,18 @@ const NAV_MENU_ICON_KEYS: Record = { heightKey: "IconSidebarExpandImgHeight", fallbackTag: "FiChevronsRight", }, + docsLock: { + lightKey: "IconDocsLockLightImg", + darkKey: "IconDocsLockDarkImg", + useReactKey: "IconDocsLockReactIcones", + tagKey: "IconDocsLockReactIconesTag", + colorDarkKey: "IconDocsLockReactIconesTagColorDark", + colorLightKey: "IconDocsLockReactIconesTagColorLight", + sizeKey: "IconDocsLockReactIconesTagSize", + widthKey: "IconDocsLockImgWidth", + heightKey: "IconDocsLockImgHeight", + fallbackTag: "FiLock", + }, }; function resolveIconFromKeys( diff --git a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon.ts b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon.ts index d7b3c1d..d59a98d 100644 --- a/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon.ts +++ b/frontend/src/shared/lib/icons/nav-menu/resolve-nav-menu-icon.ts @@ -15,6 +15,7 @@ const resolveBlockActive = createNavMenuIconResolver("blockActive"); const resolveBlockInactive = createNavMenuIconResolver("blockInactive"); const resolveSidebarCollapse = createNavMenuIconResolver("sidebarCollapse"); const resolveSidebarExpand = createNavMenuIconResolver("sidebarExpand"); +const resolveDocsLock = createNavMenuIconResolver("docsLock"); export function resolveNavMenuOpenIconConfig( site: NavMenuIconConfigInput | undefined, @@ -85,3 +86,11 @@ export function resolveSidebarExpandIconConfig( ): ResolvedNavMenuIconConfig { return resolveSidebarExpand(site, mode, basePath); } + +export function resolveDocsLockIconConfig( + site: NavMenuIconConfigInput | undefined, + mode: "dark" | "light", + basePath: string, +): ResolvedNavMenuIconConfig { + return resolveDocsLock(site, mode, basePath); +} diff --git a/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx b/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx index 856410e..32f8dc2 100644 --- a/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx +++ b/frontend/src/widgets/ai-chat-drawer/ui/ai-chat-drawer.tsx @@ -464,8 +464,8 @@ export const AiChatDrawer: React.FC = ({ isOpen, onClose, ico
{pendingAttachments.length > 0 && (
- {pendingAttachments.length} arquivo(s) anexado(s) - + {pendingAttachments.length} {labels.aiChatAttachedFilesLabel} +
)}
@@ -497,7 +497,7 @@ export const AiChatDrawer: React.FC = ({ isOpen, onClose, ico className={styles.sendButton} data-active={inputValue.trim().length > 0 || isLoading} disabled={!inputValue.trim() && !isLoading} - title={isLoading ? "Cancelar Resposta" : labels.aiChatSendBtn} + title={isLoading ? labels.aiChatCancelResponseLabel : labels.aiChatSendBtn} > {isLoading ? renderIcon(icons.cancel, "FiXCircle") : renderIcon(icons.send, "FiSend")} diff --git a/frontend/src/widgets/docs-shell/docs-shell.tsx b/frontend/src/widgets/docs-shell/docs-shell.tsx index 8221531..e362af6 100644 --- a/frontend/src/widgets/docs-shell/docs-shell.tsx +++ b/frontend/src/widgets/docs-shell/docs-shell.tsx @@ -19,6 +19,8 @@ import { resolveRouteGuideIconConfig } from "@/shared/lib/resolve-site-assets"; import { useFocusMode } from "./model/use-focus-mode"; import { useNavMenuBlockPreference } from "@/features/nav-menu-block-preference"; import { useRouteAuthorization } from "@/features/route-authorization"; +import { useDocsAccess, DocsAccessGate } from "@/features/docs-access"; +import { resolveDocsLockIconConfig } from "@/shared/lib/icons/nav-menu/resolve-nav-menu-icon"; import { useQuickNavigation } from "./model/use-quick-navigation"; import { useVersionRouting } from "./model/use-version-routing"; import { CollapsedNavRail } from "./ui/docs-shell-collapsed-rail"; @@ -359,6 +361,25 @@ export function DocsShell({ data }: { data: LoadedDocsData }) { [data.config.site, nextMode], ); + const docsAccess = useDocsAccess(data.config.site.docsAccess, data.config.site.name); + const docsLockIconConfig = useMemo( + () => resolveDocsLockIconConfig(data.config.site, nextMode === "dark" ? "dark" : "light", getBasePath()), + [data.config.site, nextMode], + ); + const docsLockProps = docsAccess.enabled + ? { + icon: docsLockIconConfig, + texts: { + tooltip: labels.docsAccessLockTooltip, + popupTitle: labels.docsAccessBlockPopupTitle, + popupDescription: labels.docsAccessBlockPopupDesc, + confirmText: labels.docsAccessBlockConfirmBtn, + cancelText: labels.docsAccessBlockCancelBtn, + }, + onConfirmBlock: docsAccess.lockAgain, + } + : undefined; + const [isAiChatOpen, setAiChatOpen] = useState(false); const aiChatIconConfig = useMemo( () => ({ @@ -417,6 +438,27 @@ export function DocsShell({ data }: { data: LoadedDocsData }) { onToggleMode, }; + if (docsAccess.state === "loading") { + return
; + } + + if (docsAccess.state === "locked") { + return ( +
+ +
+ ); + } + return (
@@ -440,6 +482,7 @@ export function DocsShell({ data }: { data: LoadedDocsData }) { navMenuConfig={navMenuConfig} onOpenAiChat={() => setAiChatOpen(true)} aiChatIconConfig={isAiChatEnabledGlobal ? aiChatIconConfig : undefined} + docsLock={docsLockProps} /> {!sidebarOpen && ( diff --git a/frontend/src/widgets/docs-shell/model/use-docs-shell-labels.ts b/frontend/src/widgets/docs-shell/model/use-docs-shell-labels.ts index 2a2f381..3010444 100644 --- a/frontend/src/widgets/docs-shell/model/use-docs-shell-labels.ts +++ b/frontend/src/widgets/docs-shell/model/use-docs-shell-labels.ts @@ -57,6 +57,25 @@ export interface DocsShellLabels { aiChatError429: string; aiChatError500: string; aiChatErrorGeneric: string; + aiChatSaveStartBtn: string; + aiChatAttachedFilesLabel: string; + aiChatRemoveAttachmentBtn: string; + aiChatCancelResponseLabel: string; + aiChatResetBtn: string; + aiChatResetPopupTitle: string; + aiChatResetPopupDesc: string; + aiChatResetConfirmBtn: string; + aiChatResetCancelBtn: string; + docsAccessGateTitle: string; + docsAccessGateDescription: string; + docsAccessInputPlaceholder: string; + docsAccessUnlockBtn: string; + docsAccessWrongCredential: string; + docsAccessLockTooltip: string; + docsAccessBlockPopupTitle: string; + docsAccessBlockPopupDesc: string; + docsAccessBlockConfirmBtn: string; + docsAccessBlockCancelBtn: string; } export function useDocsShellLabels(data: LoadedDocsData, language: string): DocsShellLabels { @@ -180,6 +199,29 @@ export function useDocsShellLabels(data: LoadedDocsData, language: string): Docs const aiChatError500 = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatError500", "An internal server error occurred on the AI model."); const aiChatErrorGeneric = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatErrorGeneric", "An unexpected error occurred while communicating with the AI."); + // Chat strings migrated from hardcoded literals + const aiChatSaveStartBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatSaveStartBtn", "Save & Start Chatting"); + const aiChatAttachedFilesLabel = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatAttachedFilesLabel", "file(s) attached"); + const aiChatRemoveAttachmentBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatRemoveAttachmentBtn", "Remove"); + const aiChatCancelResponseLabel = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatCancelResponseLabel", "Cancel response"); + const aiChatResetBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatResetBtn", "Forgot password?"); + const aiChatResetPopupTitle = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatResetPopupTitle", "Reset password?"); + const aiChatResetPopupDesc = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatResetPopupDesc", "This erases all saved API keys and the local password. You'll create a new password next time."); + const aiChatResetConfirmBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatResetConfirmBtn", "Yes, reset"); + const aiChatResetCancelBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "aiChatResetCancelBtn", "Cancel"); + + // Documentation access gate + const docsAccessGateTitle = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessGateTitle", "Protected documentation"); + const docsAccessGateDescription = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessGateDescription", "Enter the password or private key to view the documentation."); + const docsAccessInputPlaceholder = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessInputPlaceholder", "Password or private key"); + const docsAccessUnlockBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessUnlockBtn", "Unlock"); + const docsAccessWrongCredential = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessWrongCredential", "Incorrect password or key."); + const docsAccessLockTooltip = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessLockTooltip", "Lock documentation"); + const docsAccessBlockPopupTitle = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessBlockPopupTitle", "Block the documentation?"); + const docsAccessBlockPopupDesc = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessBlockPopupDesc", "You'll need the password again on your next visit."); + const docsAccessBlockConfirmBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessBlockConfirmBtn", "Yes, block"); + const docsAccessBlockCancelBtn = getLangMenuLabelFromMenu(data.config.site.langmenu, language, "docsAccessBlockCancelBtn", "Cancel"); + return { previousLabel, nextLabel, @@ -236,6 +278,25 @@ export function useDocsShellLabels(data: LoadedDocsData, language: string): Docs aiChatError429, aiChatError500, aiChatErrorGeneric, + aiChatSaveStartBtn, + aiChatAttachedFilesLabel, + aiChatRemoveAttachmentBtn, + aiChatCancelResponseLabel, + aiChatResetBtn, + aiChatResetPopupTitle, + aiChatResetPopupDesc, + aiChatResetConfirmBtn, + aiChatResetCancelBtn, + docsAccessGateTitle, + docsAccessGateDescription, + docsAccessInputPlaceholder, + docsAccessUnlockBtn, + docsAccessWrongCredential, + docsAccessLockTooltip, + docsAccessBlockPopupTitle, + docsAccessBlockPopupDesc, + docsAccessBlockConfirmBtn, + docsAccessBlockCancelBtn, }; }, [data.config.site.langmenu, data.config.translations, language]); } diff --git a/frontend/src/widgets/docs-shell/ui/docs-shell-sidebar.tsx b/frontend/src/widgets/docs-shell/ui/docs-shell-sidebar.tsx index e2998ba..e475efc 100644 --- a/frontend/src/widgets/docs-shell/ui/docs-shell-sidebar.tsx +++ b/frontend/src/widgets/docs-shell/ui/docs-shell-sidebar.tsx @@ -2,6 +2,8 @@ import Image from "next/image"; import { BsMoonStarsFill, BsSunFill } from "react-icons/bs"; import { ReactIconByTag } from "@/shared/ui/react-icon-by-tag"; import { NavMenuBlockToggle } from "@/features/nav-menu-block-preference"; +import { DocsLockButton, type DocsLockTexts } from "@/features/docs-access"; +import type { ResolvedNavMenuIconConfig } from "@/shared/lib/icons/nav-menu/resolve-nav-menu-icon"; import type { NavMenuConfig } from "../model/use-docs-shell-config"; import type { MenuNode } from "../model/menu-tree"; import { DocsShellMenuTree } from "./docs-shell-menu-tree"; @@ -27,6 +29,11 @@ interface DocsShellSidebarProps { navMenuConfig: NavMenuConfig; onOpenAiChat: () => void; aiChatIconConfig: any; + docsLock?: { + icon: ResolvedNavMenuIconConfig; + texts: DocsLockTexts; + onConfirmBlock: () => void; + }; } export function DocsShellSidebar({ @@ -49,6 +56,7 @@ export function DocsShellSidebar({ navMenuConfig, onOpenAiChat, aiChatIconConfig, + docsLock, }: DocsShellSidebarProps) { return (