diff --git a/.gitignore b/.gitignore index a701b05a8..20fef5ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ node_modules !telegraf-build/scripts !telegraf-build/README.md /cypress/screenshots/* +/cypress/videos/* +test-results.xml /influxdb3cli-build-scripts/content .vscode/* .idea diff --git a/.husky/_/serve b/.husky/_/serve new file mode 100755 index 000000000..df25a7d09 --- /dev/null +++ b/.husky/_/serve @@ -0,0 +1,57 @@ +#!/bin/sh + +if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then + set -x +fi + +if [ "$LEFTHOOK" = "0" ]; then + exit 0 +fi + +call_lefthook() +{ + if test -n "$LEFTHOOK_BIN" + then + "$LEFTHOOK_BIN" "$@" + elif lefthook -h >/dev/null 2>&1 + then + lefthook "$@" + else + dir="$(git rev-parse --show-toplevel)" + osArch=$(uname | tr '[:upper:]' '[:lower:]') + cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') + if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" + then + "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@" + elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" + then + "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@" + elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" + then + "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@" + elif test -f "$dir/node_modules/lefthook/bin/index.js" + then + "$dir/node_modules/lefthook/bin/index.js" "$@" + + elif bundle exec lefthook -h >/dev/null 2>&1 + then + bundle exec lefthook "$@" + elif yarn lefthook -h >/dev/null 2>&1 + then + yarn lefthook "$@" + elif pnpm lefthook -h >/dev/null 2>&1 + then + pnpm lefthook "$@" + elif swift package plugin lefthook >/dev/null 2>&1 + then + swift package --disable-sandbox plugin lefthook "$@" + elif command -v mint >/dev/null 2>&1 + then + mint run csjones/lefthook-plugin "$@" + else + echo "Can't find lefthook in PATH" + fi + fi +} + +call_lefthook run "serve" "$@" diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..945c17819 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v23.10.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dad714a79..ecf5c9cd4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,8 +28,10 @@ For the linting and tests to run, you need to install Docker and Node.js dependencies. \_**Note:** -We strongly recommend running linting and tests, but you can skip them -(and avoid installing dependencies) +The git pre-commit and pre-push hooks are configured to run linting and tests automatically +when you commit or push changes. +We strongly recommend letting them run, but you can skip them +(and avoid installing related dependencies) by including the `--no-verify` flag with your commit--for example, enter the following command in your terminal: ```sh @@ -51,7 +53,7 @@ dev dependencies used in pre-commit hooks for linting, syntax-checking, and test Dev dependencies include: - [Lefthook](https://github.com/evilmartians/lefthook): configures and -manages pre-commit hooks for linting and testing Markdown content. +manages git pre-commit and pre-push hooks for linting and testing Markdown content. - [prettier](https://prettier.io/docs/en/): formats code, including Markdown, according to style rules for consistency - [Cypress]: e2e testing for UI elements and URLs in content @@ -93,9 +95,11 @@ Make your suggested changes being sure to follow the [style and formatting guide ## Lint and test your changes +`package.json` contains scripts for running tests and linting. + ### Automatic pre-commit checks -docs-v2 uses Lefthook to manage Git hooks, such as pre-commit hooks that lint Markdown and test code blocks. +docs-v2 uses Lefthook to manage Git hooks that run during pre-commit and pre-push. The hooks run the scripts defined in `package.json` to lint Markdown and test code blocks. When you try to commit changes (`git commit`), Git runs the commands configured in `lefthook.yml` which pass your **staged** files to Vale, Prettier, Cypress (for UI tests and link-checking), and Pytest (for testing Python and shell code in code blocks). diff --git a/assets/js/index.js b/assets/js/index.js new file mode 100644 index 000000000..f63ad8b5d --- /dev/null +++ b/assets/js/index.js @@ -0,0 +1 @@ +export * from './main.js'; diff --git a/assets/js/main.js b/assets/js/main.js index 57b92a837..5c2289720 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -6,9 +6,6 @@ /** Import modules that are not components. * TODO: Refactor these into single-purpose component modules. */ -// import * as codeblocksPreferences from './api-libs.js'; -// import * as datetime from './datetime.js'; -// import * as featureCallouts from './feature-callouts.js'; import * as apiLibs from './api-libs.js'; import * as codeControls from './code-controls.js'; import * as contentInteractions from './content-interactions.js'; @@ -21,15 +18,6 @@ import * as pageContext from './page-context.js'; import * as pageFeedback from './page-feedback.js'; import * as tabbedContent from './tabbed-content.js'; import * as v3Wayfinding from './v3-wayfinding.js'; -// import * as homeInteractions from './home-interactions.js'; -// import { getUrls, getReferrerHost, InfluxDBUrl } from './influxdb-url.js'; -// import * as keybindings from './keybindings.js'; -// import * as listFilters from './list-filters.js'; -// import { Modal } from './modal.js'; -// import { showNotifications } from './notifications.js'; -// import ReleaseTOC from './release-toc.js'; -// import * as scroll from './scroll.js'; -// import { TabbedContent } from './tabbed-content.js'; /** Import component modules * The component pattern organizes JavaScript, CSS, and HTML for a specific UI element or interaction: @@ -41,40 +29,95 @@ import * as v3Wayfinding from './v3-wayfinding.js'; import AskAITrigger from './ask-ai-trigger.js'; import CodePlaceholder from './code-placeholders.js'; import { CustomTimeTrigger } from './custom-timestamps.js'; +import FluxInfluxDBVersionsTrigger from './flux-influxdb-versions.js'; import { SearchButton } from './search-button.js'; import { SidebarToggle } from './sidebar-toggle.js'; import Theme from './theme.js'; import ThemeSwitch from './theme-switch.js'; -// import CodeControls from './code-controls.js'; -// import ContentInteractions from './content-interactions.js'; -// import CustomTimestamps from './custom-timestamps.js'; -// import Diagram from './Diagram.js'; -// import FluxGroupKeysExample from './FluxGroupKeysExample.js'; -import FluxInfluxDBVersionsTrigger from './flux-influxdb-versions.js'; -// import PageFeedback from './page-feedback.js'; -// import SearchInput from './SearchInput.js'; -// import Sidebar from './Sidebar.js'; -// import V3Wayfinding from './v3-wayfinding.js'; -// import VersionSelector from './VersionSelector.js'; -// Expose libraries and components within a namespaced object (for backwards compatibility or testing) -// Expose libraries and components within a namespaced object (for backwards compatibility or testing) +/** + * Component Registry + * A central registry that maps component names to their constructor functions. + * Add new components to this registry as they are created or migrated from non-component modules. + * This allows for: + * 1. Automatic component initialization based on data-component attributes + * 2. Centralized component management + * 3. Easy addition/removal of components + * 4. Simplified testing and debugging + */ +const componentRegistry = { + 'ask-ai-trigger': AskAITrigger, + 'code-placeholder': CodePlaceholder, + 'custom-time-trigger': CustomTimeTrigger, + 'flux-influxdb-versions-trigger': FluxInfluxDBVersionsTrigger, + 'search-button': SearchButton, + 'sidebar-toggle': SidebarToggle, + 'theme': Theme, + 'theme-switch': ThemeSwitch +}; - - -document.addEventListener('DOMContentLoaded', function () { +/** + * Initialize global namespace for documentation JavaScript + * Exposes core modules for debugging, testing, and backwards compatibility + */ +function initGlobals() { if (typeof window.influxdatadocs === 'undefined') { window.influxdatadocs = {}; } - // Expose modules to the global object for debugging, testing, and backwards compatibility for non-ES6 modules. + // Expose modules to the global object for debugging, testing, and backwards compatibility window.influxdatadocs.delay = delay; window.influxdatadocs.localStorage = window.LocalStorageAPI = localStorage; window.influxdatadocs.pageContext = pageContext; window.influxdatadocs.toggleModal = modals.toggleModal; + window.influxdatadocs.componentRegistry = componentRegistry; + + return window.influxdatadocs; +} - // On content loaded, initialize (not-component-ready) UI interaction modules - // To differentiate these from component-ready modules, these modules typically export an initialize function that wraps UI interactions and event listeners. +/** + * Initialize components based on data-component attributes + * @param {Object} globals - The global influxdatadocs namespace + */ +function initComponents(globals) { + const components = document.querySelectorAll('[data-component]'); + + components.forEach((component) => { + const componentName = component.getAttribute('data-component'); + const ComponentConstructor = componentRegistry[componentName]; + + if (ComponentConstructor) { + // Initialize the component and store its instance in the global namespace + try { + const instance = ComponentConstructor({ component }); + globals[componentName] = ComponentConstructor; + + // Optionally store component instances for future reference + if (!globals.instances) { + globals.instances = {}; + } + + if (!globals.instances[componentName]) { + globals.instances[componentName] = []; + } + + globals.instances[componentName].push({ + element: component, + instance + }); + } catch (error) { + console.error(`Error initializing component "${componentName}":`, error); + } + } else { + console.warn(`Unknown component: "${componentName}"`); + } + }); +} + +/** + * Initialize all non-component modules + */ +function initModules() { modals.initialize(); apiLibs.initialize(); codeControls.initialize(); @@ -84,67 +127,24 @@ document.addEventListener('DOMContentLoaded', function () { pageFeedback.initialize(); tabbedContent.initialize(); v3Wayfinding.initialize(); +} - /** Initialize components - Component Structure: Each component is structured as a jQuery anonymous function that listens for the document ready state. - Initialization in main.js: Each component is called in main.js inside a jQuery document ready function to ensure they are initialized when the document is ready. - Note: These components should *not* be called directly in the HTML. - */ - const components = document.querySelectorAll('[data-component]'); - components.forEach((component) => { - const componentName = component.getAttribute('data-component'); - switch (componentName) { - case 'ask-ai-trigger': - AskAITrigger({ component }); - window.influxdatadocs[componentName] = AskAITrigger; - break; - case 'code-placeholder': - CodePlaceholder({ component }); - window.influxdatadocs[componentName] = CodePlaceholder; - break; - case 'custom-time-trigger': - CustomTimeTrigger({ component }); - window.influxdatadocs[componentName] = CustomTimeTrigger; - break; - case 'flux-influxdb-versions-trigger': - FluxInfluxDBVersionsTrigger({ component }); - window.influxdatadocs[componentName] = FluxInfluxDBVersionsTrigger; - break; - case 'search-button': - SearchButton({ component }); - window.influxdatadocs[componentName] = SearchButton; - break; - case 'sidebar-toggle': - SidebarToggle({ component }); - window.influxdatadocs[componentName] = SidebarToggle; - break; - case 'theme': - Theme({ component }); - window.influxdatadocs[componentName] = Theme; - break; - // CodeControls(); - // ContentInteractions(); - // CustomTimestamps(); - // Diagram(); - // FluxGroupKeysExample(); - // FluxInfluxDBVersionsModal(); - // InfluxDBUrl(); - // Modal(); - // PageFeedback(); - // ReleaseTOC(); - // SearchInput(); - // showNotifications(); - // Sidebar(); - // TabbedContent(); - // ThemeSwitch({}); - // V3Wayfinding(); - // VersionSelector(); - case 'theme-switch': - ThemeSwitch({ component }); - window.influxdatadocs[componentName] = ThemeSwitch; - break; - default: - console.warn(`Unknown component: ${componentName}`); - } - }); -}); +/** + * Main initialization function + */ +function init() { + // Initialize global namespace and expose core modules + const globals = initGlobals(); + + // Initialize non-component UI modules + initModules(); + + // Initialize components from registry + initComponents(globals); +} + +// Initialize everything when the DOM is ready +document.addEventListener('DOMContentLoaded', init); + +// Export public API +export { initGlobals, componentRegistry }; \ No newline at end of file diff --git a/assets/jsconfig.json b/assets/jsconfig.json index 377218ccb..4ad710c10 100644 --- a/assets/jsconfig.json +++ b/assets/jsconfig.json @@ -3,7 +3,8 @@ "baseUrl": ".", "paths": { "*": [ - "*" + "*", + "../node_modules/*" ] } } diff --git a/cypress.config.js b/cypress.config.js index 6bc148d05..eb82bea59 100644 --- a/cypress.config.js +++ b/cypress.config.js @@ -1,10 +1,16 @@ -const { defineConfig } = require('cypress'); -const process = require('process'); +import { defineConfig } from 'cypress'; +import { cwd as _cwd } from 'process'; +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { + BROKEN_LINKS_FILE, + initializeReport, + readBrokenLinksReport, +} from './cypress/support/link-reporter.js'; -module.exports = defineConfig({ +export default defineConfig({ e2e: { - // Automatically prefix cy.visit() and cy.request() commands with a baseUrl. - baseUrl: 'http://localhost:1313', + baseUrl: 'http://localhost:1315', defaultCommandTimeout: 10000, pageLoadTimeout: 30000, responseTimeout: 30000, @@ -12,34 +18,128 @@ module.exports = defineConfig({ numTestsKeptInMemory: 5, projectId: 'influxdata-docs', setupNodeEvents(on, config) { - // implement node event listeners here + // Browser setup on('before:browser:launch', (browser, launchOptions) => { if (browser.name === 'chrome' && browser.isHeadless) { - // Force Chrome to use a less memory-intensive approach launchOptions.args.push('--disable-dev-shm-usage'); launchOptions.args.push('--disable-gpu'); launchOptions.args.push('--disable-extensions'); return launchOptions; } }); + on('task', { // Fetch the product list configured in /data/products.yml getData(filename) { return new Promise((resolve, reject) => { - const yq = require('js-yaml'); - const fs = require('fs'); - const cwd = process.cwd(); + const cwd = _cwd(); try { resolve( - yq.load(fs.readFileSync(`${cwd}/data/${filename}.yml`, 'utf8')) + yaml.load( + fs.readFileSync(`${cwd}/data/${filename}.yml`, 'utf8') + ) ); } catch (e) { reject(e); } }); }, + + // Log task for reporting + log(message) { + if (typeof message === 'object') { + if (message.type === 'error') { + console.error(`\x1b[31m${message.message}\x1b[0m`); // Red + } else if (message.type === 'warning') { + console.warn(`\x1b[33m${message.message}\x1b[0m`); // Yellow + } else if (message.type === 'success') { + console.log(`\x1b[32m${message.message}\x1b[0m`); // Green + } else if (message.type === 'divider') { + console.log(`\x1b[90m${message.message}\x1b[0m`); // Gray + } else { + console.log(message.message || message); + } + } else { + console.log(message); + } + return null; + }, + + // File tasks + writeFile({ path, content }) { + try { + fs.writeFileSync(path, content); + return true; + } catch (error) { + console.error(`Error writing to file ${path}: ${error.message}`); + return { error: error.message }; + } + }, + + readFile(path) { + try { + return fs.existsSync(path) ? fs.readFileSync(path, 'utf8') : null; + } catch (error) { + console.error(`Error reading file ${path}: ${error.message}`); + return { error: error.message }; + } + }, + + // Broken links reporting tasks + initializeBrokenLinksReport() { + return initializeReport(); + }, + + reportBrokenLink(linkData) { + try { + // Read current report + const report = readBrokenLinksReport(); + + // Find or create entry for this page + let pageReport = report.find((r) => r.page === linkData.page); + if (!pageReport) { + pageReport = { page: linkData.page, links: [] }; + report.push(pageReport); + } + + // Add the broken link to the page's report + pageReport.links.push({ + url: linkData.url, + status: linkData.status, + type: linkData.type, + linkText: linkData.linkText, + }); + + // Write updated report back to file + fs.writeFileSync( + BROKEN_LINKS_FILE, + JSON.stringify(report, null, 2) + ); + + // Log the broken link immediately to console + console.error( + `❌ BROKEN LINK: ${linkData.url} (${linkData.status}) - ${linkData.type} on page ${linkData.page}` + ); + + return true; + } catch (error) { + console.error(`Error reporting broken link: ${error.message}`); + return false; + } + }, + }); + + // Load plugins file using dynamic import for ESM compatibility + return import('./cypress/plugins/index.js').then((module) => { + return module.default(on, config); }); - return config; }, + specPattern: 'cypress/e2e/**/*.cy.{js,jsx,ts,tsx}', + supportFile: 'cypress/support/e2e.js', + viewportWidth: 1280, + viewportHeight: 720, + }, + env: { + test_subjects: '', }, }); diff --git a/cypress/e2e/content/article-links.cy.js b/cypress/e2e/content/article-links.cy.js index 3aca5298c..ee1672cb2 100644 --- a/cypress/e2e/content/article-links.cy.js +++ b/cypress/e2e/content/article-links.cy.js @@ -1,11 +1,11 @@ /// -describe('Article links', () => { +describe('Article', () => { const subjects = Cypress.env('test_subjects').split(','); // Always use HEAD for downloads to avoid timeouts const useHeadForDownloads = true; - // Helper function to identify download links - improved + // Helper function to identify download links function isDownloadLink(href) { // Check for common download file extensions const downloadExtensions = [ @@ -45,130 +45,163 @@ describe('Article links', () => { } // Helper function to make appropriate request based on link type - function testLink(href) { + function testLink(href, linkText = '', pageUrl) { + // Common request options for both methods + const requestOptions = { + failOnStatusCode: true, + timeout: 15000, // Increased timeout for reliability + followRedirect: true, // Explicitly follow redirects + retryOnNetworkFailure: true, // Retry on network issues + retryOnStatusCodeFailure: true, // Retry on 5xx errors + }; + + function handleFailedLink(url, status, type, redirectChain = '') { + // Report broken link to the task which will handle reporting + cy.task('reportBrokenLink', { + url: url + redirectChain, + status, + type, + linkText, + page: pageUrl, + }); + + throw new Error( + `BROKEN ${type.toUpperCase()} LINK: ${url} (status: ${status})${redirectChain} on ${pageUrl}` + ); + } + if (useHeadForDownloads && isDownloadLink(href)) { cy.log(`** Testing download link with HEAD: ${href} **`); cy.request({ method: 'HEAD', url: href, + ...requestOptions, }).then((response) => { - const message = `Link is broken: ${href} (status: ${response.status})`; - try { - expect(response.status).to.be.lt(400); - } catch (e) { - // Log the broken link with the URL for better visibility in reports - cy.log(`❌ BROKEN LINK: ${href} (${response.status})`); - throw new Error(message); + // Check final status after following any redirects + if (response.status >= 400) { + // Build redirect info string if available + const redirectInfo = + response.redirects && response.redirects.length > 0 + ? ` (redirected to: ${response.redirects.join(' -> ')})` + : ''; + + handleFailedLink(href, response.status, 'download', redirectInfo); } }); } else { cy.log(`** Testing link: ${href} **`); + cy.log(JSON.stringify(requestOptions)); cy.request({ url: href, - failOnStatusCode: false, - timeout: 10000, // 10 second timeout for regular links + ...requestOptions, }).then((response) => { - const message = `Link is broken: ${href} (status: ${response.status})`; - try { - expect(response.status).to.be.lt(400); - } catch (e) { - // Log the broken link with the URL for better visibility in reports - cy.log(`❌ BROKEN LINK: ${href} (${response.status})`); - throw new Error(message); + // Check final status after following any redirects + if (response.status >= 400) { + // Build redirect info string if available + const redirectInfo = + response.redirects && response.redirects.length > 0 + ? ` (redirected to: ${response.redirects.join(' -> ')})` + : ''; + + handleFailedLink(href, response.status, 'regular', redirectInfo); } }); } } + // Before all tests, initialize the report + before(() => { + cy.task('initializeBrokenLinksReport'); + }); + subjects.forEach((subject) => { - it(`contains valid internal links on ${subject}`, function () { - cy.visit(`${subject}`); + it(`${subject} has valid internal links`, function () { + cy.visit(`${subject}`, { timeout: 20000 }); + // Test internal links - // 1. Timeout and fail the test if article is not found - // 2. Check each link. - // 3. If no links are found, continue without failing - cy.get('article').then(($article) => { + cy.get('article, .api-content').then(($article) => { // Find links without failing the test if none are found const $links = $article.find('a[href^="/"]'); if ($links.length === 0) { cy.log('No internal links found on this page'); return; } + + // Now test each link cy.wrap($links).each(($a) => { const href = $a.attr('href'); - testLink(href); + const linkText = $a.text().trim(); + testLink(href, linkText, subject); }); }); }); - it(`checks anchor links on ${subject} (with warnings for missing targets)`, function () { + it(`${subject} has valid anchor links`, function () { cy.visit(`${subject}`); - // Track missing anchors for summary - const missingAnchors = []; + // Define selectors for anchor links to ignore, such as behavior triggers + const ignoreLinks = ['.tabs a[href^="#"]', '.code-tabs a[href^="#"]']; - // Process anchor links individually - cy.get('article').then(($article) => { - const $anchorLinks = $article.find('a[href^="#"]'); + const anchorSelector = + 'a[href^="#"]:not(' + ignoreLinks.join('):not(') + ')'; + + cy.get('article, .api-content').then(($article) => { + const $anchorLinks = $article.find(anchorSelector); if ($anchorLinks.length === 0) { cy.log('No anchor links found on this page'); return; } + cy.wrap($anchorLinks).each(($a) => { - const href = $a.prop('href'); - if (href && href.length > 1) { - // Skip empty anchors (#) - // Get just the fragment part - const url = new URL(href); - const anchorId = url.hash.substring(1); // Remove the # character + const href = $a.prop('href'); + const linkText = $a.text().trim(); - if (!anchorId) { - cy.log(`Skipping empty anchor in ${href}`); - return; + if (href && href.length > 1) { + // Get just the fragment part + const url = new URL(href); + const anchorId = url.hash.substring(1); // Remove the # character + + if (!anchorId) { + cy.log(`Skipping empty anchor in ${href}`); + return; + } + + // Use DOM to check if the element exists + cy.window().then((win) => { + const element = win.document.getElementById(anchorId); + if (!element) { + cy.task('reportBrokenLink', { + url: `#${anchorId}`, + status: 404, + type: 'anchor', + linkText, + page: subject, + }); + cy.log(`⚠️ Missing anchor target: #${anchorId}`); } - - // Use DOM to check if the element exists, but don't fail if missing - cy.window().then((win) => { - const element = win.document.getElementById(anchorId); - if (element) { - cy.log(`✅ Anchor target exists: #${anchorId}`); - } else { - // Just warn about the missing anchor - cy.log(`⚠️ WARNING: Missing anchor target: #${anchorId}`); - missingAnchors.push(anchorId); - } - }); - } - }) - .then(() => { - // After checking all anchors, log a summary - if (missingAnchors.length > 0) { - cy.log( - `⚠️ Found ${missingAnchors.length} missing anchor targets: ${missingAnchors.join(', ')}` - ); - } else { - cy.log('✅ All anchor targets are valid'); - } - }); - }); - - it(`contains valid external links on ${subject}`, function () { - cy.visit(`${subject}`); - // Test external links - // 1. Timeout and fail the test if article is not found - // 2. Check each link. - // 3. If no links are found, continue without failing - cy.get('article').then(($article) => { - // Find links without failing the test if none are found - const $links = $article.find('a[href^="http"]'); - if ($links.length === 0) { - cy.log('No external links found on this page'); - return; + }); } - cy.wrap($links).each(($a) => { - const href = $a.attr('href'); - testLink(href); - }); + }); + }); + }); + + it(`${subject} has valid external links`, function () { + cy.visit(`${subject}`); + + // Test external links + cy.get('article, .api-content').then(($article) => { + // Find links without failing the test if none are found + const $links = $article.find('a[href^="http"]'); + if ($links.length === 0) { + cy.log('No external links found on this page'); + return; + } + + cy.debug(`Found ${$links.length} external links`); + cy.wrap($links).each(($a) => { + const href = $a.attr('href'); + const linkText = $a.text().trim(); + testLink(href, linkText, subject); }); }); }); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 000000000..904971cc0 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,26 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +export default (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config + + // NOTE: The log task is now defined in cypress.config.js + // We don't need to register it here to avoid duplication + + return config; +}; diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index 3eaffffa6..b0265634d 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -14,4 +14,4 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import './commands' \ No newline at end of file +import './commands'; diff --git a/cypress/support/hugo-server.js b/cypress/support/hugo-server.js new file mode 100644 index 000000000..42411bfd9 --- /dev/null +++ b/cypress/support/hugo-server.js @@ -0,0 +1,174 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import http from 'http'; +import net from 'net'; + +// Hugo server constants +export const HUGO_PORT = 1315; +export const HUGO_LOG_FILE = '/tmp/hugo_server.log'; + +/** + * Check if a port is already in use + * @param {number} port - The port to check + * @returns {Promise} True if port is in use, false otherwise + */ +export async function isPortInUse(port) { + return new Promise((resolve) => { + const tester = net + .createServer() + .once('error', () => resolve(true)) + .once('listening', () => { + tester.close(); + resolve(false); + }) + .listen(port, '127.0.0.1'); + }); +} + +/** + * Start the Hugo server with the specified options + * @param {Object} options - Configuration options for Hugo + * @param {string} options.configFile - Path to Hugo config file (e.g., 'hugo.testing.yml') + * @param {number} options.port - Port number for Hugo server + * @param {boolean} options.buildDrafts - Whether to build draft content + * @param {boolean} options.noHTTPCache - Whether to disable HTTP caching + * @param {string} options.logFile - Path to write Hugo logs + * @returns {Promise} Child process object + */ +export async function startHugoServer({ + configFile = 'hugo.testing.yml', + port = HUGO_PORT, + buildDrafts = true, + noHTTPCache = true, + logFile = HUGO_LOG_FILE, +} = {}) { + console.log(`Starting Hugo server on port ${port}...`); + + // Prepare command arguments + const hugoArgs = [ + 'hugo', + 'server', + '--config', + configFile, + '--port', + String(port), + ]; + + if (buildDrafts) { + hugoArgs.push('--buildDrafts'); + } + + if (noHTTPCache) { + hugoArgs.push('--noHTTPCache'); + } + + return new Promise((resolve, reject) => { + try { + // Use npx to find and execute Hugo, which will work regardless of installation method + console.log(`Running Hugo with npx: npx ${hugoArgs.join(' ')}`); + const hugoProc = spawn('npx', hugoArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + }); + + // Check if the process started successfully + if (!hugoProc || !hugoProc.pid) { + return reject(new Error('Failed to start Hugo server via npx')); + } + + // Set up logging + if (logFile) { + hugoProc.stdout.on('data', (data) => { + const output = data.toString(); + fs.appendFileSync(logFile, output); + process.stdout.write(`Hugo: ${output}`); + }); + + hugoProc.stderr.on('data', (data) => { + const output = data.toString(); + fs.appendFileSync(logFile, output); + process.stderr.write(`Hugo ERROR: ${output}`); + }); + } + + // Handle process errors + hugoProc.on('error', (err) => { + console.error(`Error in Hugo server process: ${err}`); + reject(err); + }); + + // Check for early exit + hugoProc.on('close', (code) => { + if (code !== null && code !== 0) { + reject(new Error(`Hugo process exited early with code ${code}`)); + } + }); + + // Resolve with the process object after a short delay to ensure it's running + setTimeout(() => { + if (hugoProc.killed) { + reject(new Error('Hugo process was killed during startup')); + } else { + resolve(hugoProc); + } + }, 500); + } catch (err) { + console.error(`Error starting Hugo server: ${err.message}`); + reject(err); + } + }); +} + +/** + * Wait for the Hugo server to be ready + * @param {number} timeoutMs - Timeout in milliseconds + * @returns {Promise} + */ +export async function waitForHugoReady(timeoutMs = 30000) { + console.log( + `Waiting for Hugo server to be ready on http://localhost:${HUGO_PORT}...` + ); + + const startTime = Date.now(); + + return new Promise((resolve, reject) => { + // Poll the server + function checkServer() { + const req = http.get(`http://localhost:${HUGO_PORT}`, (res) => { + if (res.statusCode === 200) { + resolve(); + } else { + // If we get a response but not 200, try again after delay + const elapsed = Date.now() - startTime; + if (elapsed > timeoutMs) { + reject( + new Error( + `Hugo server responded with status ${res.statusCode} after timeout` + ) + ); + } else { + setTimeout(checkServer, 1000); + } + } + }); + + req.on('error', (err) => { + // Connection errors are expected while server is starting + const elapsed = Date.now() - startTime; + if (elapsed > timeoutMs) { + reject( + new Error(`Timed out waiting for Hugo server: ${err.message}`) + ); + } else { + // Try again after a delay + setTimeout(checkServer, 1000); + } + }); + + req.end(); + } + + // Start polling + checkServer(); + }); +} diff --git a/cypress/support/link-reporter.js b/cypress/support/link-reporter.js new file mode 100644 index 000000000..7c450007b --- /dev/null +++ b/cypress/support/link-reporter.js @@ -0,0 +1,120 @@ +/** + * Broken Links Reporter + * Handles collecting, storing, and reporting broken links found during tests + */ +import fs from 'fs'; + +export const BROKEN_LINKS_FILE = '/tmp/broken_links_report.json'; +const SOURCES_FILE = '/tmp/test_subjects_sources.json'; + +/** + * Reads the broken links report from the file system + * @returns {Array} Parsed report data or empty array if file doesn't exist + */ +export function readBrokenLinksReport() { + if (!fs.existsSync(BROKEN_LINKS_FILE)) { + return []; + } + + try { + const fileContent = fs.readFileSync(BROKEN_LINKS_FILE, 'utf8'); + return fileContent && fileContent !== '[]' ? JSON.parse(fileContent) : []; + } catch (err) { + console.error(`Error reading broken links report: ${err.message}`); + return []; + } +} + +/** + * Reads the sources mapping file + * @returns {Object} A mapping from URLs to their source files + */ +function readSourcesMapping() { + try { + if (fs.existsSync(SOURCES_FILE)) { + const sourcesData = JSON.parse(fs.readFileSync(SOURCES_FILE, 'utf8')); + return sourcesData.reduce((acc, item) => { + if (item.url && item.source) { + acc[item.url] = item.source; + } + return acc; + }, {}); + } + } catch (err) { + console.warn(`Warning: Could not read sources mapping: ${err.message}`); + } + return {}; +} + +/** + * Formats and displays the broken links report to the console + * @param {Array} brokenLinksReport - The report data to display + * @returns {number} The total number of broken links found + */ +export function displayBrokenLinksReport(brokenLinksReport = null) { + // If no report provided, read from file + if (!brokenLinksReport) { + brokenLinksReport = readBrokenLinksReport(); + } + + if (!brokenLinksReport || brokenLinksReport.length === 0) { + console.log('✅ No broken links detected'); + return 0; + } + + // Load sources mapping + const sourcesMapping = readSourcesMapping(); + + // Print a prominent header + console.error('\n\n' + '='.repeat(80)); + console.error(' 🚨 BROKEN LINKS DETECTED 🚨 '); + console.error('='.repeat(80)); + + let totalBrokenLinks = 0; + + brokenLinksReport.forEach((report) => { + console.error(`\n📄 PAGE: ${report.page}`); + + // Add source information if available + const source = sourcesMapping[report.page]; + if (source) { + console.error(` PAGE CONTENT SOURCE: ${source}`); + } + + console.error('-'.repeat(40)); + + report.links.forEach((link) => { + console.error(`• ${link.url}`); + console.error(` - Status: ${link.status}`); + console.error(` - Type: ${link.type}`); + if (link.linkText) { + console.error( + ` - Link text: "${link.linkText.substring(0, 50)}${link.linkText.length > 50 ? '...' : ''}"` + ); + } + console.error(''); + totalBrokenLinks++; + }); + }); + + // Print a prominent summary footer + console.error('='.repeat(80)); + console.error(`📊 TOTAL BROKEN LINKS FOUND: ${totalBrokenLinks}`); + console.error('='.repeat(80) + '\n'); + + return totalBrokenLinks; +} + +/** + * Initialize the broken links report file + * @returns {boolean} True if initialization was successful + */ +export function initializeReport() { + try { + fs.writeFileSync(BROKEN_LINKS_FILE, '[]', 'utf8'); + return true; + } catch (err) { + console.error(`Error initializing broken links report: ${err.message}`); + return false; + } +} diff --git a/cypress/support/map-files-to-urls.js b/cypress/support/map-files-to-urls.js index d41d9a8c5..bde313fc8 100644 --- a/cypress/support/map-files-to-urls.js +++ b/cypress/support/map-files-to-urls.js @@ -1,79 +1,139 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; import process from 'process'; +import fs from 'fs'; +import { execSync } from 'child_process'; +import matter from 'gray-matter'; // Get file paths from command line arguments -const filePaths = process.argv.slice(2); +const filePaths = process.argv.slice(2).filter((arg) => !arg.startsWith('--')); // Parse options -const debugMode = process.argv.includes('--debug'); +const debugMode = process.argv.includes('--debug'); // deprecated, no longer used +const jsonMode = process.argv.includes('--json'); -// Filter for content files -const contentFiles = filePaths.filter(file => - file.startsWith('content/') && (file.endsWith('.md') || file.endsWith('.html')) +// Separate shared content files and regular content files +const sharedContentFiles = filePaths.filter( + (file) => + file.startsWith('content/shared/') && + (file.endsWith('.md') || file.endsWith('.html')) ); -if (contentFiles.length === 0) { - console.log('No content files to check.'); +const regularContentFiles = filePaths.filter( + (file) => + file.startsWith('content/') && + !file.startsWith('content/shared/') && + (file.endsWith('.md') || file.endsWith('.html')) +); + +// Find pages that reference shared content files in their frontmatter +function findPagesReferencingSharedContent(sharedFilePath) { + try { + // Remove the leading "content/" to match how it would appear in frontmatter + const relativePath = sharedFilePath.replace(/^content\//, ''); + + // Use grep to find files that reference this shared content in frontmatter + // Look for source: pattern in YAML frontmatter + const grepCmd = `grep -l "source: .*${relativePath}" --include="*.md" --include="*.html" -r content/`; + + // Execute grep command and parse results + const result = execSync(grepCmd, { encoding: 'utf8' }).trim(); + + if (!result) { + return []; + } + + return result.split('\n').filter(Boolean); + } catch (error) { + // grep returns non-zero exit code when no matches are found + if (error.status === 1) { + return []; + } + console.error( + `Error finding references to ${sharedFilePath}: ${error.message}` + ); + return []; + } +} + +/** + * Extract source from frontmatter or use the file path as source + * @param {string} filePath - Path to the file + * @returns {string} Source path + */ +function extractSourceFromFile(filePath) { + try { + if (fs.existsSync(filePath)) { + const fileContent = fs.readFileSync(filePath, 'utf8'); + const { data } = matter(fileContent); + + // If source is specified in frontmatter, return it + if (data.source) { + if (data.source.startsWith('/shared')) { + return 'content' + data.source; + } + return data.source; + } + } + + // If no source in frontmatter or can't read file, use the file path itself + return filePath; + } catch (error) { + console.error(`Error extracting source from ${filePath}: ${error.message}`); + return filePath; + } +} + +// Process shared content files to find pages that reference them +let pagesToTest = [...regularContentFiles]; + +if (sharedContentFiles.length > 0) { + console.log( + `Processing ${sharedContentFiles.length} shared content files...` + ); + + for (const sharedFile of sharedContentFiles) { + const referencingPages = findPagesReferencingSharedContent(sharedFile); + + if (referencingPages.length > 0) { + console.log( + `Found ${referencingPages.length} pages referencing ${sharedFile}` + ); + // Add referencing pages to the list of pages to test (avoid duplicates) + pagesToTest = [...new Set([...pagesToTest, ...referencingPages])]; + } else { + console.log(`No pages found referencing ${sharedFile}`); + } + } +} + +if (pagesToTest.length === 0) { + console.log('No content files to map.'); process.exit(0); } -// Map file paths to URL paths -function mapFilePathToUrl(filePath) { - // Remove content/ prefix +// Map file paths to URL paths and source information +function mapFilePathToUrlAndSource(filePath) { + // Map to URL let url = filePath.replace(/^content/, ''); - - // Handle _index files (both .html and .md) url = url.replace(/\/_index\.(html|md)$/, '/'); - - // Handle regular .md files url = url.replace(/\.md$/, '/'); - - // Handle regular .html files url = url.replace(/\.html$/, '/'); - - // Ensure URL starts with a slash if (!url.startsWith('/')) { url = '/' + url; } - - return url; + + // Extract source + const source = extractSourceFromFile(filePath); + + return { url, source }; } -const urls = contentFiles.map(mapFilePathToUrl); -const urlList = urls.join(','); +const mappedFiles = pagesToTest.map(mapFilePathToUrlAndSource); -console.log(`Testing links in URLs: ${urlList}`); - -// Create environment object with the cypress_test_subjects variable -const envVars = { - ...process.env, - cypress_test_subjects: urlList, - NODE_OPTIONS: '--max-http-header-size=80000 --max-old-space-size=4096' -}; - -// Run Cypress tests with the mapped URLs -try { - // Choose run mode based on debug flag - if (debugMode) { - // For debug mode, set the environment variable and open Cypress - // The user will need to manually select the test file - console.log('Opening Cypress in debug mode.'); - console.log('Please select the "article-links.cy.js" test file when Cypress opens.'); - - execSync('npx cypress open --e2e', { - stdio: 'inherit', - env: envVars - }); - } else { - // For normal mode, run the test automatically - execSync(`npx cypress run --spec "cypress/e2e/content/article-links.cy.js"`, { - stdio: 'inherit', - env: envVars - }); - } -} catch (error) { - console.error('Link check failed:', error); - process.exit(1); -} \ No newline at end of file +if (jsonMode) { + console.log(JSON.stringify(mappedFiles, null, 2)); +} else { + // Print URL and source info in a format that's easy to parse + mappedFiles.forEach((item) => console.log(`${item.url}|${item.source}`)); +} diff --git a/cypress/support/map-files-to-urls.mjs b/cypress/support/map-files-to-urls.mjs deleted file mode 100644 index 8ded4157f..000000000 --- a/cypress/support/map-files-to-urls.mjs +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env node - -import { execSync } from 'child_process'; -import process from 'process'; - -// Get file paths from command line arguments -const filePaths = process.argv.slice(2); - -// Parse options -const debugMode = process.argv.includes('--debug'); - -// Filter for content files -const contentFiles = filePaths.filter(file => - file.startsWith('content/') && (file.endsWith('.md') || file.endsWith('.html')) -); - -if (contentFiles.length === 0) { - console.log('No content files to check.'); - process.exit(0); -} - -// Map file paths to URL paths -function mapFilePathToUrl(filePath) { - // Remove content/ prefix - let url = filePath.replace(/^content/, ''); - - // Handle _index files (both .html and .md) - url = url.replace(/\/_index\.(html|md)$/, '/'); - - // Handle regular .md files - url = url.replace(/\.md$/, '/'); - - // Handle regular .html files - url = url.replace(/\.html$/, '/'); - - // Ensure URL starts with a slash - if (!url.startsWith('/')) { - url = '/' + url; - } - - return url; -} - -const urls = contentFiles.map(mapFilePathToUrl); -const urlList = urls.join(','); - -console.log(`Testing links in URLs: ${urlList}`); - -// Create environment object with the cypress_test_subjects variable -const envVars = { - ...process.env, - cypress_test_subjects: urlList, - NODE_OPTIONS: '--max-http-header-size=80000 --max-old-space-size=4096' -}; - -// Run Cypress tests with the mapped URLs -try { - // Choose run mode based on debug flag - if (debugMode) { - // For debug mode, set the environment variable and open Cypress - // The user will need to manually select the test file - console.log('Opening Cypress in debug mode.'); - console.log('Please select the "article-links.cy.js" test file when Cypress opens.'); - - execSync('npx cypress open --e2e', { - stdio: 'inherit', - env: envVars - }); - } else { - // For normal mode, run the test automatically - execSync(`npx cypress run --spec "cypress/e2e/content/article-links.cy.js"`, { - stdio: 'inherit', - env: envVars - }); - } -} catch (error) { - console.error('Link check failed'); - process.exit(1); -} \ No newline at end of file diff --git a/cypress/support/run-e2e-specs.js b/cypress/support/run-e2e-specs.js new file mode 100644 index 000000000..f5eab2ae7 --- /dev/null +++ b/cypress/support/run-e2e-specs.js @@ -0,0 +1,412 @@ +/** + * InfluxData Documentation E2E Test Runner + * + * This script automates running Cypress end-to-end tests for the InfluxData documentation site. + * It handles starting a local Hugo server, mapping content files to their URLs, running Cypress tests, + * and reporting broken links. + * + * Usage: node run-e2e-specs.js [file paths...] [--spec test-spec-path] + * + * Example: node run-e2e-specs.js content/influxdb/v2/write-data.md --spec cypress/e2e/content/article-links.cy.js + */ + +import { spawn } from 'child_process'; +import process from 'process'; +import fs from 'fs'; +import path from 'path'; +import cypress from 'cypress'; +import net from 'net'; +import matter from 'gray-matter'; +import { displayBrokenLinksReport } from './link-reporter.js'; +import { + HUGO_PORT, + HUGO_LOG_FILE, + startHugoServer, + waitForHugoReady, +} from './hugo-server.js'; + +const MAP_SCRIPT = path.resolve('cypress/support/map-files-to-urls.js'); +const URLS_FILE = '/tmp/test_subjects.txt'; + +/** + * Parses command line arguments into file and spec arguments + * @param {string[]} argv - Command line arguments (process.argv) + * @returns {Object} Object containing fileArgs and specArgs arrays + */ +function parseArgs(argv) { + const fileArgs = []; + const specArgs = []; + let i = 2; // Start at index 2 to skip 'node' and script name + + while (i < argv.length) { + if (argv[i] === '--spec') { + i++; + if (i < argv.length) { + specArgs.push(argv[i]); + i++; + } + } else { + fileArgs.push(argv[i]); + i++; + } + } + + return { fileArgs, specArgs }; +} + +// Check if port is already in use +async function isPortInUse(port) { + return new Promise((resolve) => { + const tester = net + .createServer() + .once('error', () => resolve(true)) + .once('listening', () => { + tester.close(); + resolve(false); + }) + .listen(port, '127.0.0.1'); + }); +} + +/** + * Extract source information from frontmatter + * @param {string} filePath - Path to the markdown file + * @returns {string|null} Source information if present + */ +function getSourceFromFrontmatter(filePath) { + if (!fs.existsSync(filePath)) { + return null; + } + + try { + const fileContent = fs.readFileSync(filePath, 'utf8'); + const { data } = matter(fileContent); + return data.source || null; + } catch (err) { + console.warn( + `Warning: Could not extract frontmatter from ${filePath}: ${err.message}` + ); + return null; + } +} + +/** + * Ensures a directory exists, creating it if necessary + * Also creates an empty file to ensure the directory is not empty + * @param {string} dirPath - The directory path to ensure exists + */ +function ensureDirectoryExists(dirPath) { + if (!fs.existsSync(dirPath)) { + try { + fs.mkdirSync(dirPath, { recursive: true }); + console.log(`Created directory: ${dirPath}`); + + // Create an empty .gitkeep file to ensure the directory exists and isn't empty + fs.writeFileSync(path.join(dirPath, '.gitkeep'), ''); + } catch (err) { + console.warn( + `Warning: Could not create directory ${dirPath}: ${err.message}` + ); + } + } +} + +async function main() { + // Keep track of processes to cleanly shut down + let hugoProc = null; + let exitCode = 0; + let hugoStarted = false; + + // Add this signal handler to ensure cleanup on unexpected termination + const cleanupAndExit = (code = 1) => { + console.log(`Performing cleanup before exit with code ${code}...`); + if (hugoProc && hugoStarted) { + try { + hugoProc.kill('SIGKILL'); // Use SIGKILL to ensure immediate termination + } catch (err) { + console.error(`Error killing Hugo process: ${err.message}`); + } + } + process.exit(code); + }; + + // Handle various termination signals + process.on('SIGINT', () => cleanupAndExit(1)); + process.on('SIGTERM', () => cleanupAndExit(1)); + process.on('uncaughtException', (err) => { + console.error(`Uncaught exception: ${err.message}`); + cleanupAndExit(1); + }); + + const { fileArgs, specArgs } = parseArgs(process.argv); + if (fileArgs.length === 0) { + console.error('No file paths provided.'); + process.exit(1); + } + + // 1. Map file paths to URLs and write to file + const mapProc = spawn('node', [MAP_SCRIPT, ...fileArgs], { + stdio: ['ignore', 'pipe', 'inherit'], + }); + + const mappingOutput = []; + mapProc.stdout.on('data', (chunk) => { + mappingOutput.push(chunk.toString()); + }); + + await new Promise((res) => mapProc.on('close', res)); + + // Process the mapping output + const urlList = mappingOutput + .join('') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + // Parse the URL|SOURCE format + if (line.includes('|')) { + const [url, source] = line.split('|'); + return { url, source }; + } else if (line.startsWith('/')) { + // Handle URLs without source (should not happen with our new code) + return { url: line, source: null }; + } else { + // Skip log messages + return null; + } + }) + .filter(Boolean); // Remove null entries + + // Log the URLs and sources we'll be testing + console.log(`Found ${urlList.length} URLs to test:`); + urlList.forEach(({ url, source }) => { + console.log(` URL: ${url}`); + console.log(` PAGE CONTENT SOURCE: ${source}`); + console.log('---'); + }); + + if (urlList.length === 0) { + console.log('No URLs to test.'); + process.exit(0); + } + + // Write just the URLs to the test_subjects file for Cypress + fs.writeFileSync(URLS_FILE, urlList.map((item) => item.url).join(',')); + + // Add source information to a separate file for reference during reporting + fs.writeFileSync( + '/tmp/test_subjects_sources.json', + JSON.stringify(urlList, null, 2) + ); + + // 2. Check if port is in use before starting Hugo + const portInUse = await isPortInUse(HUGO_PORT); + + if (portInUse) { + console.log( + `Port ${HUGO_PORT} is already in use. Checking if Hugo is running...` + ); + try { + // Try to connect to verify it's a working server + await waitForHugoReady(5000); // Short timeout - if it's running, it should respond quickly + console.log( + `Hugo server already running on port ${HUGO_PORT}, will use existing instance` + ); + } catch (err) { + console.error( + `Port ${HUGO_PORT} is in use but not responding as expected: ${err.message}` + ); + console.error('Please stop any processes using this port and try again.'); + process.exit(1); + } + } else { + // Start Hugo server using the imported function + try { + console.log( + `Starting Hugo server (logs will be written to ${HUGO_LOG_FILE})...` + ); + + // Create or clear the log file + fs.writeFileSync(HUGO_LOG_FILE, ''); + + // First, check if Hugo is installed and available + try { + // Try running a simple Hugo version command to check if Hugo is available + const hugoCheck = spawn('hugo', ['version'], { shell: true }); + await new Promise((resolve, reject) => { + hugoCheck.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Hugo check failed with code ${code}`)); + } + }); + hugoCheck.on('error', (err) => reject(err)); + }); + + console.log('Hugo is available on the system'); + } catch (checkErr) { + console.log( + 'Hugo not found on PATH, will use project-local Hugo via yarn' + ); + } + + // Use the startHugoServer function from hugo-server.js + hugoProc = await startHugoServer({ + configFile: 'hugo.testing.yml', + port: HUGO_PORT, + buildDrafts: true, + noHTTPCache: true, + logFile: HUGO_LOG_FILE, + }); + + // Ensure hugoProc is a valid process object with kill method + if (!hugoProc || typeof hugoProc.kill !== 'function') { + throw new Error('Failed to get a valid Hugo process object'); + } + + hugoStarted = true; + console.log(`Started Hugo process with PID: ${hugoProc.pid}`); + + // Wait for Hugo to be ready + await waitForHugoReady(); + console.log(`Hugo server ready on port ${HUGO_PORT}`); + } catch (err) { + console.error(`Error starting or waiting for Hugo: ${err.message}`); + if (hugoProc && typeof hugoProc.kill === 'function') { + hugoProc.kill('SIGTERM'); + } + process.exit(1); + } + } + + // 3. Prepare Cypress directories + try { + const screenshotsDir = path.resolve('cypress/screenshots'); + const videosDir = path.resolve('cypress/videos'); + const specScreenshotDir = path.join(screenshotsDir, 'article-links.cy.js'); + + // Ensure base directories exist + ensureDirectoryExists(screenshotsDir); + ensureDirectoryExists(videosDir); + + // Create spec-specific screenshot directory with a placeholder file + ensureDirectoryExists(specScreenshotDir); + + // Create a dummy screenshot file to prevent trash errors + const dummyScreenshotPath = path.join(specScreenshotDir, 'dummy.png'); + if (!fs.existsSync(dummyScreenshotPath)) { + // Create a minimal valid PNG file (1x1 transparent pixel) + const minimalPng = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, + 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00, + 0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, + 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + fs.writeFileSync(dummyScreenshotPath, minimalPng); + console.log(`Created dummy screenshot file: ${dummyScreenshotPath}`); + } + + console.log('Cypress directories prepared successfully'); + } catch (err) { + console.warn( + `Warning: Error preparing Cypress directories: ${err.message}` + ); + // Continue execution - this is not a fatal error + } + + // 4. Run Cypress tests + let cypressFailed = false; + try { + console.log(`Running Cypress tests for ${urlList.length} URLs...`); + const cypressOptions = { + reporter: 'junit', + browser: 'chrome', + config: { + baseUrl: `http://localhost:${HUGO_PORT}`, + video: true, + trashAssetsBeforeRuns: false, // Prevent trash errors + }, + env: { + // Pass URLs as a comma-separated string for backward compatibility + test_subjects: urlList.map((item) => item.url).join(','), + // Add new structured data with source information + test_subjects_data: JSON.stringify(urlList), + }, + }; + + if (specArgs.length > 0) { + console.log(`Using specified test specs: ${specArgs.join(', ')}`); + cypressOptions.spec = specArgs.join(','); + } + + const results = await cypress.run(cypressOptions); + + // Process broken links report + const brokenLinksCount = displayBrokenLinksReport(); + + if ( + (results && results.totalFailed && results.totalFailed > 0) || + brokenLinksCount > 0 + ) { + console.error( + `⚠️ Tests failed: ${results.totalFailed || 0} test(s) failed, ${brokenLinksCount || 0} broken links found` + ); + cypressFailed = true; + exitCode = 1; + } else if (results) { + console.log('✅ Tests completed successfully'); + } + } catch (err) { + console.error(`❌ Cypress execution error: ${err.message}`); + console.error( + `Check Hugo server logs at ${HUGO_LOG_FILE} for any server issues` + ); + + // Still try to display broken links report if available + displayBrokenLinksReport(); + + cypressFailed = true; + exitCode = 1; + } finally { + // Stop Hugo server only if we started it + if (hugoProc && hugoStarted && typeof hugoProc.kill === 'function') { + console.log(`Stopping Hugo server (fast shutdown: ${cypressFailed})...`); + + if (cypressFailed) { + hugoProc.kill('SIGKILL'); + console.log('Hugo server forcibly terminated'); + } else { + const shutdownTimeout = setTimeout(() => { + console.error( + 'Hugo server did not shut down gracefully, forcing termination' + ); + hugoProc.kill('SIGKILL'); + process.exit(exitCode); + }, 2000); + + hugoProc.kill('SIGTERM'); + + hugoProc.on('close', () => { + clearTimeout(shutdownTimeout); + console.log('Hugo server shut down successfully'); + process.exit(exitCode); + }); + + // Return to prevent immediate exit + return; + } + } else if (hugoStarted) { + console.log('Hugo process was started but is not available for cleanup'); + } + + process.exit(exitCode); + } +} + +main().catch((err) => { + console.error(`Fatal error: ${err}`); + process.exit(1); +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 6d1eeba04..22e12b4db 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,98 @@ import globals from "globals"; import pluginJs from "@eslint/js"; - +import tseslint from "typescript-eslint"; +import importPlugin from "eslint-plugin-import"; +import a11yPlugin from "eslint-plugin-jsx-a11y"; +import prettierConfig from "eslint-config-prettier"; /** @type {import('eslint').Linter.Config[]} */ export default [ - {languageOptions: { globals: globals.browser }}, + // Base configurations + { + languageOptions: { + globals: { + ...globals.browser, + // Hugo-specific globals + hugo: "readonly", + params: "readonly", + // Common libraries used in docs + Alpine: "readonly", + CodeMirror: "readonly", + d3: "readonly" + }, + ecmaVersion: 2022, + sourceType: "module", + } + }, pluginJs.configs.recommended, + + // TypeScript configurations (for .ts files) + ...tseslint.configs.recommended, + + // Import plugin for better import/export handling + importPlugin.configs.recommended, + + // Accessibility rules (helpful for docs site) + a11yPlugin.configs.recommended, + + // Prettier compatibility + prettierConfig, + + // Custom rules for documentation project + { + rules: { + // Documentation projects often need to use console for examples + "no-console": "off", + + // Module imports + "import/extensions": ["error", "ignorePackages"], + "import/no-unresolved": "off", // Hugo handles module resolution differently + + // Code formatting + "max-len": ["warn", { "code": 80, "ignoreUrls": true, "ignoreStrings": true }], + "quotes": ["error", "single", { "avoidEscape": true }], + + // Documentation-specific + "valid-jsdoc": ["warn", { + "requireReturn": false, + "requireReturnType": false, + "requireParamType": false + }], + + // Hugo template string linting (custom rule) + "no-template-curly-in-string": "off", // Allow ${} in strings for Hugo templates + + // Accessibility + "jsx-a11y/anchor-is-valid": "warn", + } + }, + + // Configuration for specific file patterns + { + files: ["**/*.js"], + rules: { + // Rules specific to JavaScript files + } + }, + { + files: ["assets/js/**/*.js"], + rules: { + // Rules specific to JavaScript in Hugo assets + } + }, + { + files: ["**/*.ts"], + rules: { + // Rules specific to TypeScript files + } + }, + { + // Ignore rules for build files and external dependencies + ignores: [ + "**/node_modules/**", + "**/public/**", + "**/resources/**", + "**/.hugo_build.lock" + ] + } ]; \ No newline at end of file diff --git a/hugo.testing.yml b/hugo.testing.yml new file mode 100644 index 000000000..5dbbe6f4a --- /dev/null +++ b/hugo.testing.yml @@ -0,0 +1,21 @@ +# config.testing.toml +# This configuration inherits from the base config and overrides draft settings + +# Import base configuration +imports: ["hugo.yml"] + +# Override settings for testing +buildFuture: true + +# Configure what content is built in testing env +params: + environment: testing + buildTestContent: true + +# Keep your shared content exclusions +ignoreFiles: + - "content/shared/.*" + +# Ignore specific warning logs +ignoreLogs: + - warning-goldmark-raw-html \ No newline at end of file diff --git a/hugo.yml b/hugo.yml index cb4775438..909917486 100644 --- a/hugo.yml +++ b/hugo.yml @@ -54,3 +54,16 @@ outputFormats: mediaType: application/json baseName: pages isPlainText: true + +build: + # Ensure Hugo correctly processes JavaScript modules + jsConfig: + nodeEnv: "development" + +module: + mounts: + - source: assets + target: assets + + - source: node_modules + target: assets/node_modules \ No newline at end of file diff --git a/layouts/partials/header/javascript.html b/layouts/partials/header/javascript.html index 2f029ece6..747777861 100644 --- a/layouts/partials/header/javascript.html +++ b/layouts/partials/header/javascript.html @@ -1,13 +1,3 @@ - - -{{ $jquery := resources.Get "js/jquery-3.5.0.min.js" }} -{{ $headerjs := slice $jquery | resources.Concat "js/header.bundle.js" | resources.Fingerprint }} - - - {{ $productPathData := findRE "[^/]+.*?" .RelPermalink }} {{ $product := index $productPathData 0 }} @@ -23,7 +13,7 @@ {{ $products := .Site.Data.products }} {{ $influxdb_urls := .Site.Data.influxdb_urls }} -{{ with resources.Get "js/main.js" }} +{{ with resources.Get "js/index.js" }} {{ $opts := dict "minify" hugo.IsProduction "sourceMap" (cond hugo.IsProduction "" "external") diff --git a/lefthook.yml b/lefthook.yml index ecf91b6dc..e813fa67e 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -1,135 +1,86 @@ # Refer for explanation to following link: # https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md # -pre-push: - commands: - packages-audit: - tags: frontend security - run: yarn audit + pre-commit: - parallel: true + parallel: true commands: + # Report linting warnings and errors, don't output files to stdout lint-markdown: tags: lint - glob: "content/**/*.md" + glob: 'content/*.md' run: | docker compose run --rm --name remark-lint remark-lint '{staged_files}' cloud-lint: tags: lint,v2 - glob: "content/influxdb/cloud/**/*.md" + glob: 'content/influxdb/cloud/*.md' run: '.ci/vale/vale.sh --config=.vale.ini --minAlertLevel=error {staged_files}' cloud-dedicated-lint: tags: lint,v3 - glob: "content/influxdb/cloud-dedicated/**/*.md" + glob: 'content/influxdb/cloud-dedicated/*.md' run: '.ci/vale/vale.sh --config=content/influxdb/cloud-dedicated/.vale.ini --minAlertLevel=error {staged_files}' cloud-serverless-lint: tags: lint,v3 - glob: "content/influxdb/cloud-serverless/**/*.md" + glob: 'content/influxdb/cloud-serverless/*.md' run: '.ci/vale/vale.sh --config=content/influxdb/cloud-serverless/.vale.ini --minAlertLevel=error {staged_files}' clustered-lint: tags: lint,v3 - glob: "content/influxdb/clustered/**/*.md" + glob: 'content/influxdb/clustered/*.md' run: '.ci/vale/vale.sh --config=content/influxdb/cloud-serverless/.vale.ini --minAlertLevel=error {staged_files}' telegraf-lint: - tags: lint,clients - glob: "content/telegraf/**/*.md" + tags: lint,clients + glob: 'content/telegraf/*.md' run: '.ci/vale/vale.sh --config=.vale.ini --minAlertLevel=error {staged_files}' v2-lint: tags: lint,v2 - glob: "content/influxdb/v2/**/*.md" + glob: 'content/influxdb/v2/*.md' run: '.ci/vale/vale.sh --config=content/influxdb/v2/.vale.ini --minAlertLevel=error {staged_files}' - - # Link checking for InfluxDB v2 - v2-links: - tags: test,links,v2 - glob: "content/influxdb/v2/**/*.{md,html}" - run: 'node cypress/support/map-files-to-urls.mjs {staged_files}' - - # Link checking for InfluxDB v3 core - v3-core-links: - tags: test,links,v3 - glob: "content/influxdb3/core/**/*.{md,html}" - run: 'node cypress/support/map-files-to-urls.mjs {staged_files}' - - # Link checking for InfluxDB v3 enterprise - v3-enterprise-links: - tags: test,links,v3 - glob: "content/influxdb3/enterprise/**/*.{md,html}" - run: 'node cypress/support/map-files-to-urls.mjs {staged_files}' - - # Link checking for Cloud products - cloud-links: - tags: test,links,cloud - glob: "content/influxdb/{cloud,cloud-dedicated,cloud-serverless}/**/*.{md,html}" - run: 'node cypress/support/map-files-to-urls.mjs {staged_files}' - - # Link checking for Telegraf - telegraf-links: - tags: test,links - glob: "content/telegraf/**/*.{md,html}" - run: 'node cypress/support/map-files-to-urls.mjs {staged_files}' - - cloud-pytest: - glob: content/influxdb/cloud/**/*.md - tags: test,codeblocks,v2 - env: - - SERVICE: cloud-pytest - run: docker compose run --rm --name $SERVICE $SERVICE '{staged_files}' - cloud-dedicated-pytest: - tags: test,codeblocks,v3 - glob: content/influxdb/cloud-dedicated/**/*.md - env: - - SERVICE: cloud-dedicated-pytest - run: | - ./test/scripts/monitor-tests.sh start $SERVICE ; - docker compose run --name $SERVICE $SERVICE {staged_files} ; - ./test/scripts/monitor-tests.sh stop $SERVICE - cloud-serverless-pytest: - tags: test,codeblocks,v3 - glob: content/influxdb/cloud-serverless/**/*.md - env: - - SERVICE: cloud-serverless-pytest - run: docker compose run --rm --name $SERVICE $SERVICE '{staged_files}' - clustered-pytest: - tags: test,codeblocks,v3 - glob: content/influxdb/clustered/**/*.md - env: - - SERVICE: clustered-pytest - run: | - ./test/scripts/monitor-tests.sh start $SERVICE ; - docker compose run --name $SERVICE $SERVICE {staged_files} ; - ./test/scripts/monitor-tests.sh stop $SERVICE - telegraf-pytest: - tags: test,codeblocks - glob: content/telegraf/**/*.md - env: - - SERVICE: telegraf-pytest - run: docker compose run --rm --name $SERVICE $SERVICE '{staged_files}' - v2-pytest: - tags: test,codeblocks,v2 - glob: content/influxdb/v2/**/*.md - env: - - SERVICE: v2-pytest - run: docker compose run --rm --name $SERVICE $SERVICE '{staged_files}' prettier: - tags: frontend,style - glob: "*.{css,js,ts,jsx,tsx}" - run: yarn prettier {staged_files} - -build: + tags: [frontend, style] + glob: '*.{css,js,ts,jsx,tsx}' + run: | + yarn prettier --write --loglevel silent "{staged_files}" > /dev/null 2>&1 || + { echo "⚠️ Prettier found formatting issues. Automatic formatting applied." + git add {staged_files} + } +pre-push: commands: + packages-audit: + tags: frontend security + run: yarn audit + + e2e-shortcode-examples: + tags: [frontend, test] + glob: + - assets/*.{js,mjs,css,scss} + - layouts/*.html + - content/example.md + files: /bin/ls content/example.md + run: | + node cypress/support/run-e2e-specs.js --spec "cypress/e2e/content/article-links.cy.js" {files} + exit $? + + e2e-links: + tags: test,links + glob: 'content/**/*.{md,html}' + run: | + echo "Running link checker for: {staged_files}" + yarn test:links {staged_files} + exit $? + + # Manage Docker containers prune-legacy-containers: priority: 1 tags: test @@ -137,6 +88,48 @@ build: --filter label=tag=influxdata-docs --filter status=exited | xargs docker rm) || true' - rebuild-test-images: + build-pytest-image: tags: test - run: docker compose build pytest-codeblocks + run: yarn build:pytest:image + # Test code blocks in markdown files + cloud-pytest: + glob: content/influxdb/cloud/**/*.md + tags: test,codeblocks,v2 + env: + SERVICE: cloud-pytest + run: yarn test:codeblocks:cloud '{staged_files}' + + cloud-dedicated-pytest: + tags: test,codeblocks,v3 + glob: content/influxdb/cloud-dedicated/**/*.md + run: | + yarn test:codeblocks:cloud-dedicated '{staged_files}' && + ./test/scripts/monitor-tests.sh stop cloud-dedicated-pytest + + cloud-serverless-pytest: + tags: test,codeblocks,v3 + glob: content/influxdb/cloud-serverless/**/*.md + env: + SERVICE: cloud-serverless-pytest + run: yarn test:codeblocks:cloud-serverless '{staged_files}' + + clustered-pytest: + tags: test,codeblocks,v3 + glob: content/influxdb/clustered/**/*.md + run: | + yarn test:codeblocks:clustered '{staged_files}' && + ./test/scripts/monitor-tests.sh stop clustered-pytest + + telegraf-pytest: + tags: test,codeblocks + glob: content/telegraf/**/*.md + env: + SERVICE: telegraf-pytest + run: yarn test:codeblocks:telegraf '{staged_files}' + + v2-pytest: + tags: test,codeblocks,v2 + glob: content/influxdb/v2/**/*.md + env: + SERVICE: v2-pytest + run: yarn test:codeblocks:v2 '{staged_files}' \ No newline at end of file diff --git a/package.json b/package.json index ad433c21a..6eada8864 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "axios": "^1.7.4", + "gray-matter": "^4.0.3", "jquery": "^3.7.1", "js-cookie": "^3.0.5", "js-yaml": "^4.1.0", @@ -33,16 +34,48 @@ "vanillajs-datepicker": "^1.3.4" }, "scripts": { - "e2e:chrome": "npx cypress run --browser chrome", - "e2e:o": "npx cypress open", - "e2e:o:links": "node cypress/support/map-files-to-urls.mjs content/influxdb3/core/get-started/_index.md --debug", - "e2e:api-docs": "export cypress_test_subjects=\"http://localhost:1313/influxdb3/core/api/,http://localhost:1313/influxdb3/enterprise/api/,http://localhost:1313/influxdb3/cloud-dedicated/api/,http://localhost:1313/influxdb3/cloud-dedicated/api/v1/,http://localhost:1313/influxdb/cloud-dedicated/api/v1/,http://localhost:1313/influxdb/cloud-dedicated/api/management/,http://localhost:1313/influxdb3/cloud-dedicated/api/management/\"; npx cypress run --spec cypress/e2e/article-links.cy.js", - "lint": "LEFTHOOK_EXCLUDE=test lefthook run pre-commit && lefthook run pre-push", + "build:pytest:image":"docker build -t influxdata/docs-pytest:latest -f Dockerfile.pytest .", + "lint": "LEFTHOOK_EXCLUDE=test lefthook run pre-commit && lefthook run pre-push", "pre-commit": "lefthook run pre-commit", - "test-content": "docker compose --profile test up" + "test": "echo \"Run 'yarn test:e2e', 'yarn test:links', 'yarn test:codeblocks:all' or a specific test command. e2e and links test commands can take a glob of file paths to test. Some commands run automatically during the git pre-commit and pre-push hooks.\" && exit 0", + "test:codeblocks": "echo \"Run a specific codeblocks test command\" && exit 0", + "test:codeblocks:all": "docker compose --profile test up", + "test:codeblocks:cloud": "docker compose run --rm --name cloud-pytest cloud-pytest", + "test:codeblocks:cloud-dedicated": "./test/scripts/monitor-tests.sh start cloud-dedicated-pytest && docker compose run --name cloud-dedicated-pytest cloud-dedicated-pytest", + "test:codeblocks:cloud-serverless": "docker compose run --rm --name cloud-serverless-pytest cloud-serverless-pytest", + "test:codeblocks:clustered": "./test/scripts/monitor-tests.sh start clustered-pytest && docker compose run --name clustered-pytest clustered-pytest", + "test:codeblocks:telegraf": "docker compose run --rm --name telegraf-pytest telegraf-pytest", + "test:codeblocks:v2": "docker compose run --rm --name v2-pytest v2-pytest", + "test:codeblocks:stop-monitors": "./test/scripts/monitor-tests.sh stop cloud-dedicated-pytest && ./test/scripts/monitor-tests.sh stop clustered-pytest", + "test:e2e": "node cypress/support/run-e2e-specs.js", + "test:links": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\"", + "test:links:v1": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/influxdb/{v1,enterprise_influxdb}/**/*.{md,html}", + "test:links:v2": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/influxdb/{cloud,v2}/**/*.{md,html}", + "test:links:v3": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/influxdb3/**/*.{md,html}", + "test:links:chronograf": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/chronograf/**/*.{md,html}", + "test:links:kapacitor": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/kapacitor/**/*.{md,html}", + "test:links:telegraf": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/telegraf/**/*.{md,html}", + "test:links:shared": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/shared/**/*.{md,html}", + "test:links:api-docs": "export cypress_base_url=\"http://localhost:1315\" cypress_test_subjects=\"/influxdb3/core/api/,/influxdb3/enterprise/api/,/influxdb3/cloud-dedicated/api/,/influxdb3/cloud-dedicated/api/v1/,/influxdb/cloud-dedicated/api/v1/,/influxdb/cloud-dedicated/api/management/,/influxdb3/cloud-dedicated/api/management/\"; npx cypress run --spec cypress/e2e/content/article-links.cy.js", + "test:shortcode-examples": "node cypress/support/run-e2e-specs.js --spec \"cypress/e2e/content/article-links.cy.js\" content/example.md" + }, + "main": "assets/js/main.js", + "module": "assets/js/main.js", + "exports": { + ".": { + "import": "./assets/js/main.js", + "require": "./assets/js/main.js" + } + }, + "type": "module", + "browserslist": [ + "last 2 versions", + "not dead", + "not IE 11" + ], + "engines": { + "node": ">=16.0.0" }, - "main": "index.js", - "module": "main.js", "directories": { "test": "test" }, diff --git a/yarn.lock b/yarn.lock index 055ff98d8..780fc578e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -625,6 +625,13 @@ arch@^2.2.0: resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" integrity sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ== +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + argparse@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" @@ -1790,6 +1797,11 @@ espree@^10.0.1, espree@^10.3.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.2.0" +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + esquery@^1.5.0: version "1.6.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" @@ -1846,6 +1858,13 @@ exsolve@^1.0.1: resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.4.tgz#7de5c75af82ecd15998328fbf5f2295883be3a39" integrity sha512-xsZH6PXaER4XoV+NiT7JHp1bJodJVT+cxeSH1G0f0tlT0lJqYuHUP3bUx2HtfTDvOagMINYp8rsqusxud3RXhw== +extend-shallow@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" + integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== + dependencies: + is-extendable "^0.1.0" + extend@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -2218,6 +2237,16 @@ graceful-fs@^4.1.10, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== +gray-matter@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" + integrity sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q== + dependencies: + js-yaml "^3.13.1" + kind-of "^6.0.2" + section-matter "^1.0.0" + strip-bom-string "^1.0.0" + hachure-fill@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc" @@ -2377,6 +2406,11 @@ is-core-module@^2.5.0: dependencies: hasown "^2.0.2" +is-extendable@^0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" + integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" @@ -2491,6 +2525,14 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + js-yaml@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" @@ -2576,6 +2618,11 @@ khroma@^2.1.0: resolved "https://registry.yarnpkg.com/khroma/-/khroma-2.1.0.tgz#45f2ce94ce231a437cf5b63c2e886e6eb42bbbb1" integrity sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw== +kind-of@^6.0.0, kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + kolorist@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" @@ -3576,6 +3623,14 @@ safe-stable-stringify@^2.3.1: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== +section-matter@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167" + integrity sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA== + dependencies: + extend-shallow "^2.0.1" + kind-of "^6.0.0" + seek-bzip@^1.0.5: version "1.0.6" resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.6.tgz#35c4171f55a680916b52a07859ecf3b5857f21c4" @@ -3723,6 +3778,11 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3" integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg== +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + sql-formatter@^15.0.2: version "15.4.11" resolved "https://registry.yarnpkg.com/sql-formatter/-/sql-formatter-15.4.11.tgz#10a8205aa82d60507811360d4735e81d4a21c137" @@ -3814,6 +3874,11 @@ strip-ansi@^7.0.1: dependencies: ansi-regex "^6.0.1" +strip-bom-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" + integrity sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g== + strip-dirs@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5"