diff --git a/.docsearch/config.json b/.docsearch/config.json index fbda76b9a..ea726d1d1 100644 --- a/.docsearch/config.json +++ b/.docsearch/config.json @@ -2,6 +2,96 @@ "index_name": "prod_rundeck_docs", "js_render": false, "start_urls": [ + { + "url": "https://docs.rundeck.com/(?P.*?)/learning/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["Learning"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/manual/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["User Guide"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/api/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["API"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/administration/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["Administration"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/developer/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["Developer"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/history/cves/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["Administration"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/history/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["Release Notes"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/rd-cli/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["User Guide"] + }, + { + "url": "https://docs.rundeck.com/(?P.*?)/upgrading/", + "variables": { + "version": [ + "docs", + "4.0.x" + ] + }, + "tags": ["Administration"] + }, { "url": "https://docs.rundeck.com/(?P.*?)/", "variables": { @@ -9,7 +99,8 @@ "docs", "4.0.x" ] - } + }, + "tags": ["General"] } ], "stop_urls": [ @@ -41,7 +132,8 @@ "custom_settings": { "attributesForFaceting": [ "version", - "lang" + "lang", + "tags" ] } } \ No newline at end of file diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 3abd8e779..fc283a66c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -39,4 +39,18 @@ You are an AI assistant helping maintain the Rundeck documentation site. - Code blocks should specify language for proper syntax highlighting - Use numbered lists for sequential steps - Use bullet points for non-sequential items -- Tables should have headers and consistent column formatting \ No newline at end of file +- Tables should have headers and consistent column formatting + +## Search Setup + +### Architecture +- **Search Provider**: Algolia DocSearch via `@vuepress/plugin-docsearch` +- **Index Name**: `prod_rundeck_docs` +- **Indexing**: Automated via CircleCI using `algolia/docsearch-scraper` Docker image +- **Configuration Files**: + - `.docsearch/config.json` - Algolia scraper configuration (selectors, start URLs, faceting attributes) + - `docs/.vuepress/config.ts` - VuePress DocSearch plugin configuration (appId, apiKey, search parameters) +- **Current Facets**: `version` (filters by docs version like "docs", "4.0.x") and `lang` +- **Section-Based Filtering**: Uses `tags` attribute to enable filtering by documentation section (learning, manual, api, administration, developer, etc.) +- **Indexing Strategy**: Tags are applied via URL patterns in `start_urls` to categorize content by documentation section +- **Custom Implementation**: Section filter checkboxes can be added via custom search UI using Algolia InstantSearch or DocSearch customization diff --git a/README.md b/README.md index 25123f487..a0ff120fc 100644 --- a/README.md +++ b/README.md @@ -96,9 +96,10 @@ git push origin ## Documentation Structure The documentation is organized as follows: -- `/docs/` - Main documentation content +- `/docs/` - Main documentation content (published to docs.rundeck.com) - `/docs/.vuepress/` - VuePress configuration - `/docs/.vuepress/public/assets/img/` - Images Folder +- `/dev-docs/` - Internal developer documentation (scripts, architecture, workflows) # How to Create Release Notes @@ -248,7 +249,7 @@ git push ## SaaS Development Updates Feed -For generating RSS/Atom feeds and markdown pages showing recent PRs deployed to the SaaS platform (but not yet in self-hosted releases), see [PR-FEED-README.md](./PR-FEED-README.md). +For generating RSS/Atom feeds and markdown pages showing recent PRs deployed to the SaaS platform (but not yet in self-hosted releases), see [dev-docs/PR-FEED-README.md](./dev-docs/PR-FEED-README.md). This feed automatically tracks changes since the last self-hosted release and is updated after each SaaS deployment: diff --git a/dev-docs/DOCSEARCH_FILTERS_README.md b/dev-docs/DOCSEARCH_FILTERS_README.md new file mode 100644 index 000000000..f374f147d --- /dev/null +++ b/dev-docs/DOCSEARCH_FILTERS_README.md @@ -0,0 +1,109 @@ +# DocSearch Filters Integration + +This guide explains how to integrate the section filtering component into your Rundeck documentation search. + +## Components Created + +### 1. DocSearchFilters.vue +- Location: `docs/.vuepress/components/DocSearchFilters.vue` +- A Vue component that provides a filter button with a dropdown panel +- Shows section checkboxes (Learning, User Guide, API, Administration, Developer, Release Notes, General) +- Persists filter selections in localStorage +- Dispatches custom events when filters change + +### 2. docsearch-filters.ts Plugin +- Location: `docs/.vuepress/plugins/docsearch-filters.ts` +- Client-side plugin that integrates filters with DocSearch +- Listens for filter update events and applies them to DocSearch +- Monitors DocSearch modal for filter restoration + +### 3. Client Configuration Updates +- Updated `docs/.vuepress/client.ts` to initialize the filter integration +- Imports and calls `initializeDocSearchFilters()` on app startup + +## Integration in Layout + +The filter button is **automatically injected** into the navbar by the client configuration. No manual component placement is needed. + +The `injectDocSearchFiltersIntoNavbar()` function in `client.ts` automatically: +1. Waits for the DocSearch container to be rendered +2. Creates a wrapper element next to the search container +3. Mounts the `DocSearchFilters` component dynamically + +This ensures the filter component appears right next to the search button without requiring manual template modifications. + +## How It Works + +1. **User clicks the filter button** (funnel icon with badge) +2. **Filter dropdown opens** showing available section tags +3. **User selects/deselects sections** via checkboxes +4. **Selections are stored** in localStorage for persistence +5. **Filter state is dispatched** via custom `docsearch-filters-updated` event +6. **Plugin intercepts Algolia requests** - The `docsearch-filters.ts` plugin patches `fetch` and `XMLHttpRequest` to intercept all Algolia API calls +7. **Facet filters are injected** - Selected sections are added to the request's `facetFilters` parameter as OR conditions (e.g., `tags:Learning OR tags:API`) +8. **Results are filtered by Algolia** - Algolia returns only results matching the selected section tags +9. **Search input is triggered** - An input event is dispatched to refresh the search results with the new filters applied + +## Configuration + +### Available Sections +The component currently supports these sections (from `config.json` tags): +- Learning +- User Guide +- API +- Administration +- Developer +- Release Notes +- General + +To add new sections: +1. Update `.docsearch/config.json` to add new `start_urls` with tags +2. Update the `sections` array in `DocSearchFilters.vue` + +### VuePress Configuration +The DocSearch configuration in `docs/.vuepress/config.ts` includes: +```typescript +searchParameters: { + hitsPerPage: 100, + facetFilters: [`version:${setup.base}`], + facets: ['tags'] +} +``` + +The `facets: ['tags']` tells Algolia to include tags as filterable attributes. + +## Styling + +The component uses VuePress theme variables for styling: +- `--c-brand` - Brand color +- `--c-border` - Border color +- `--c-text-secondary` - Secondary text color +- `--c-bg`, `--c-bg-light` - Background colors + +It automatically adapts to dark mode using `html.dark` selector. + +## Testing + +To test the filters: + +1. Build/run the docs: `npm run docs:dev` +2. Click the filter button in the navbar +3. Select a section (e.g., "Learning") +4. Perform a search +5. Results should only show items tagged with the selected section +6. Refresh the page - filter selections persist via localStorage + +## Troubleshooting + +### Filters not appearing in results +- Ensure Algolia index has been re-scraped with new tags +- Check that `facets: ['tags']` is in the config +- Verify the section tags match what's in `config.json` + +### localStorage not working +- Browser privacy mode disables localStorage +- Clear localStorage and refresh if there are issues + +### Component not visible +- Ensure DocSearchFilters component is imported and placed in the layout +- Check browser console for Vue component registration errors diff --git a/PR-FEED-README.md b/dev-docs/PR-FEED-README.md similarity index 99% rename from PR-FEED-README.md rename to dev-docs/PR-FEED-README.md index 48ce731c1..c80ff0b67 100644 --- a/PR-FEED-README.md +++ b/dev-docs/PR-FEED-README.md @@ -362,7 +362,7 @@ This ensures the exact commits used in the build are compared, guaranteeing accu ## Related Documentation -- **[README.md](./README.md)** - Main documentation project setup and release notes generation +- **[README.md](../README.md)** - Main documentation project setup and release notes generation - **`notes.mjs`** - Self-hosted release notes generator (uses same shared utilities) - **`pr-utils.mjs`** - Shared utility functions used by both scripts diff --git a/dev-docs/README.md b/dev-docs/README.md new file mode 100644 index 000000000..2494cea78 --- /dev/null +++ b/dev-docs/README.md @@ -0,0 +1,76 @@ +# Developer Documentation + +This directory contains internal documentation for developers working on the Rundeck documentation project. These guides cover build processes, automation scripts, and architectural decisions. + +## Contents + +### 📋 [PR-FEED-README.md](./PR-FEED-README.md) +**SaaS Development Feed Generator** + +Comprehensive guide for generating RSS/Atom feeds and markdown pages from recently merged pull requests in Rundeck repositories. This script is run as part of the SaaS release process to communicate updates deployed to Runbook Automation SaaS. + +**Key Topics:** +- Weekly SaaS release workflow +- Tag-based PR comparison +- Configuration management +- Feed generation (RSS 2.0 and Atom 1.0) +- Integration with release notes process + +**Usage:** `npm run pr-feed` + +--- + +### 🔍 [DOCSEARCH_FILTERS_README.md](./DOCSEARCH_FILTERS_README.md) +**DocSearch Section Filters Integration** + +Technical documentation for the custom DocSearch filter component that allows users to filter documentation search results by section (Learning, API, Administration, etc.). + +**Key Topics:** +- Component architecture (Vue + VuePress) +- Client-side plugin integration +- Algolia request interception +- LocalStorage persistence +- Automatic navbar injection + +**Files Covered:** +- `docs/.vuepress/components/DocSearchFilters.vue` +- `docs/.vuepress/plugins/docsearch-filters.ts` +- `docs/.vuepress/client.ts` + +--- + +## Related Documentation + +### Main Project Documentation +- **[README.md](../README.md)** - Main project README with setup, release notes, and workflow instructions +- **[.github/copilot-instructions.md](../.github/copilot-instructions.md)** - AI assistant instructions for code generation + +### Build Scripts +- **`notes.mjs`** - Self-hosted release notes generator (tag-based) +- **`pr-feed.mjs`** - SaaS development feed generator +- **`pr-utils.mjs`** - Shared utility functions + +### Configuration Files +- **`pr-feed-config.json`** - PR feed baseline configuration +- **`.docsearch/config.json`** - Algolia DocSearch configuration +- **`docs/.vuepress/config.ts`** - VuePress site configuration + +--- + +## Contributing + +When adding new internal documentation: + +1. **Place it in this directory** (`dev-docs/`) +2. **Update this README** with a description and link +3. **Reference from main README** if it's a key workflow +4. **Keep it updated** as the implementation changes + +--- + +## Notes + +- This directory is for **internal/developer documentation only** +- User-facing documentation belongs in `/docs/` +- These files are **not** published to docs.rundeck.com +- Complements the Copilot instructions in `.github/` diff --git a/docs/.vuepress/client.ts b/docs/.vuepress/client.ts index 20f170e53..b85e043cd 100644 --- a/docs/.vuepress/client.ts +++ b/docs/.vuepress/client.ts @@ -1,11 +1,13 @@ import { defineClientConfig } from '@vuepress/client' -import { nextTick } from 'vue' +import { nextTick, createApp } from 'vue' import '@docsearch/css' import Layout from "./layouts/Layout.vue"; import NotFound from "./layouts/NotFound.vue"; import CookieConsent from "./components/CookieConsent.vue"; +import DocSearchFilters from "./components/DocSearchFilters.vue"; import { loadGA4, trackPageView, setupAutoTracking, setupVideoTracking, hasConsent } from "./utils/analytics"; import { CONSENT_GRANTED_EVENT, CONSENT_REVOKED_EVENT, CONSENT_DENIED_EVENT } from "./utils/constants"; +import { initializeDocSearchFilters } from "./plugins/docsearch-filters"; declare const VERSION: string; declare const VERSION_FULL: string; @@ -73,6 +75,9 @@ export default defineClientConfig({ trackPageView(to.path); }); }); + + // Initialize DocSearch filters integration + initializeDocSearchFilters(); } // The section below is used to properly format the Search results on the docs site. @@ -142,6 +147,63 @@ export default defineClientConfig({ window.XMLHttpRequest = newXHR as any; } }, - setup() { }, + setup() { + // Inject DocSearchFilters component into the navbar + injectDocSearchFiltersIntoNavbar(); + }, rootComponents: [CookieConsent], -}) \ No newline at end of file +}) + +// Store Vue app instance to enable cleanup if needed +let filterAppInstance: ReturnType | null = null + +/** + * Inject DocSearchFilters component into the navbar next to search + * Mounts directly after the docsearch-container for tight integration + */ +function injectDocSearchFiltersIntoNavbar() { + const checkAndInject = () => { + // Target the docsearch container directly + const docsearchContainer = document.querySelector('#docsearch-container') + + if (!docsearchContainer) { + // Keep trying until docsearch is rendered + setTimeout(checkAndInject, 100) + return + } + + // Check if already injected - look for the wrapper in parent + const parent = docsearchContainer.parentElement + if (parent?.querySelector('.navbar-filters-wrapper')) { + return + } + + // Create a container for the filters + const filterContainer = document.createElement('div') + filterContainer.className = 'navbar-filters-wrapper' + filterContainer.style.display = 'flex' + filterContainer.style.alignItems = 'center' + filterContainer.style.marginLeft = '8px' + + // Insert right after the docsearch container + docsearchContainer.parentElement?.appendChild(filterContainer) + + // Unmount previous instance if it exists + if (filterAppInstance) { + try { + filterAppInstance.unmount() + } catch (e) { + console.warn('Failed to unmount previous filter app instance:', e) + } + } + + // Mount the Vue component and store the instance + filterAppInstance = createApp(DocSearchFilters) + filterAppInstance.mount(filterContainer) + } + + // Start checking after a brief delay to ensure DOM is ready + if (typeof window !== 'undefined') { + setTimeout(checkAndInject, 500) + } +} \ No newline at end of file diff --git a/docs/.vuepress/components/DocSearchFilters.vue b/docs/.vuepress/components/DocSearchFilters.vue new file mode 100644 index 000000000..4c1682525 --- /dev/null +++ b/docs/.vuepress/components/DocSearchFilters.vue @@ -0,0 +1,287 @@ + + + + + diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index f07285ff9..4269831f9 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -131,7 +131,8 @@ export default defineUserConfig({ indexName: 'prod_rundeck_docs', searchParameters: { hitsPerPage: 100, - facetFilters: [`version:${setup.base}`] + facetFilters: [`version:${setup.base}`], + facets: ['tags'] }, locales: { '/': { diff --git a/docs/.vuepress/plugins/docsearch-filters.ts b/docs/.vuepress/plugins/docsearch-filters.ts new file mode 100644 index 000000000..2f4f64cad --- /dev/null +++ b/docs/.vuepress/plugins/docsearch-filters.ts @@ -0,0 +1,167 @@ +/** + * Client-side plugin to integrate custom section filters with DocSearch + * This modifies DocSearch behavior to respect the selected section filters + */ + +// Track if integration has been set up to prevent duplicate listeners +let isIntegrationSetup = false + +export function setupDocSearchFiltersIntegration() { + if (typeof window === 'undefined') return + + // Prevent duplicate setup + if (isIntegrationSetup) return + isIntegrationSetup = true + + // Patch fetch and XHR immediately + patchAlgoliaRequests() + + // Listen for filter updates from the filter component + window.addEventListener('docsearch-filters-updated', (event: CustomEvent) => { + const { selectedSections } = event.detail + applySelectedFilters(selectedSections) + }) +} + +/** + * Intercept fetch and XHR requests to Algolia to inject selected filters + */ +function patchAlgoliaRequests() { + // Patch fetch + const originalFetch = window.fetch + + window.fetch = function(url: string | Request, options?: RequestInit) { + // Convert Request object to string if needed + let urlStr = typeof url === 'string' ? url : url.url + + if (urlStr && urlStr.includes('algolia')) { + // Intercept Algolia fetch requests + let body = options?.body + + if (body && typeof body === 'string') { + try { + const parsed = JSON.parse(body) + if (parsed.requests && Array.isArray(parsed.requests)) { + // Get selected filters from localStorage + const storedFilters = localStorage.getItem('docsearch-section-filters') + let selectedSections: string[] = [] + if (storedFilters) { + try { + selectedSections = JSON.parse(storedFilters) + } catch (e) { + // Silently fail + } + } + + // Modify each request to include our facet filters + parsed.requests.forEach((req: any) => { + if (!req.facetFilters) { + req.facetFilters = [] + } + + // Ensure facetFilters is an array + if (!Array.isArray(req.facetFilters)) { + req.facetFilters = [req.facetFilters] + } + + // Add our section filters if any are selected + if (selectedSections.length > 0) { + // Build a filter array with OR logic: tags:Learning OR tags:API + const tagFilters = selectedSections.map(section => `tags:${section}`) + req.facetFilters.push(tagFilters) + } + }) + + // Update the body with modified request + if (options) { + options.body = JSON.stringify(parsed) + } + } + } catch (e) { + // If parsing fails, just continue with original request + } + } + } + + return originalFetch.call(this, url, options) + } as any + + // Also patch XMLHttpRequest for compatibility + const originalOpen = XMLHttpRequest.prototype.open + const originalSend = XMLHttpRequest.prototype.send + + XMLHttpRequest.prototype.open = function(method: string, url: string, ...args: any[]) { + ;(this as any).__requestUrl = url + return originalOpen.apply(this, [method, url, ...args]) + } + + XMLHttpRequest.prototype.send = function(body: any) { + // Check if this is an Algolia request + const url = (this as any).__requestUrl as string + if (url && url.includes('algolia')) { + try { + // Intercept the request body and add our filters + let requestBody = body + if (typeof body === 'string') { + const parsed = JSON.parse(body) + if (parsed.requests && Array.isArray(parsed.requests)) { + // Get selected filters from localStorage + const storedFilters = localStorage.getItem('docsearch-section-filters') + let selectedSections: string[] = [] + if (storedFilters) { + try { + selectedSections = JSON.parse(storedFilters) + } catch (e) { + // Silently fail + } + } + + // Modify each request to include our facet filters + parsed.requests.forEach((req: any) => { + if (!req.facetFilters) { + req.facetFilters = [] + } + + // Ensure facetFilters is an array + if (!Array.isArray(req.facetFilters)) { + req.facetFilters = [req.facetFilters] + } + + // Add our section filters if any are selected + if (selectedSections.length > 0) { + // Build a filter array with OR logic: tags:Learning OR tags:API + const tagFilters = selectedSections.map(section => `tags:${section}`) + req.facetFilters.push(tagFilters) + } + }) + + requestBody = JSON.stringify(parsed) + } + } + + return originalSend.call(this, requestBody) + } catch (e) { + // If parsing fails, just send original request + return originalSend.call(this, body) + } + } + + return originalSend.call(this, body) + } +} + +function applySelectedFilters(selectedSections: string[]) { + // Trigger a search update by simulating input on the search field + const input = document.querySelector('.DocSearch-Input') as HTMLInputElement + if (input) { + // Dispatch an input event to trigger re-search with new filters + input.dispatchEvent(new Event('input', { bubbles: true })) + } +} + +// Export function to initialize the integration +export function initializeDocSearchFilters() { + // Setup immediately - don't wait for DOM + setupDocSearchFiltersIntegration() +} +