End-to-end testing, CI script, and JavaScript QoL improvements:

- **Environment variable formatting** - Updated environment variable configuration from array format to object format to comply with Lefthook schema validation requirements.
- **Unified link testing** - Consolidated multiple product-specific link testing commands into a single `e2e-links` command that processes all staged Markdown and HTML files across content directories.
- **Package script integration** - Modified commands to use centralized yarn scripts instead of direct execution, improving maintainability and consistency.
- **Source information extraction** - Enhanced to correctly extract and report source information from frontmatter.
- **URL and source mapping** - Improved handling of URL to source path mapping for better reporting.
- **Ignored anchor links configuration** - Added proper exclusion of behavior-triggering anchor links (like tab navigation) to prevent false positives.
- **Request options correction** - Fixed Cypress request options to ensure `failOnStatusCode` is properly set when `retryOnStatusCodeFailure` is enabled.
- **Improved error reporting** - Enhanced error reporting with more context about broken links.
- **New test scripts added** - Added centralized testing scripts for link checking and codeblock validation.
- **Product-specific test commands** - Added commands for each product version (InfluxDB v2, v3 Core, Enterprise, Cloud, etc.).
- **API docs testing** - Added specialized commands for testing API documentation links.
- **Comprehensive test runners** - Added commands to run all tests of a specific type (`test:links:all`, `test:codeblocks:all`).
- Fix Docker build command and update CONTRIBUTING.

chore(js): JavaScript QoL improvements:

- Refactor main.js with a componentRegistry object and clear initialization of components and globals
- Add a standard index.js with all necessary exports.
- Update javascript.html to use the index.js
- Remove jQuery script tag from header javascript.html (remains in footer)
- Update package file to improve module discovery.
- Improve Hugo and ESLint config for module discovery and ES6 syntax
pull/6075/head
Jason Stirnaman 2025-05-15 13:43:52 -05:00
parent 853e628880
commit 4cfff239f3
23 changed files with 1561 additions and 445 deletions

2
.gitignore vendored
View File

@ -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

57
.husky/_/serve Executable file
View File

@ -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" "$@"

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
v23.10.0

View File

@ -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).

1
assets/js/index.js Normal file
View File

@ -0,0 +1 @@
export * from './main.js';

View File

@ -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 };

View File

@ -3,7 +3,8 @@
"baseUrl": ".",
"paths": {
"*": [
"*"
"*",
"../node_modules/*"
]
}
}

View File

@ -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: '',
},
});

View File

@ -1,11 +1,11 @@
/// <reference types="cypress" />
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);
});
});
});

26
cypress/plugins/index.js Normal file
View File

@ -0,0 +1,26 @@
/// <reference types="cypress" />
// ***********************************************************
// 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;
};

View File

@ -14,4 +14,4 @@
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands'
import './commands';

View File

@ -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<boolean>} 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<Object>} 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<void>}
*/
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();
});
}

View File

@ -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;
}
}

View File

@ -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: <path> 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);
}
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}`));
}

View File

@ -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);
}

View File

@ -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);
});

View File

@ -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"
]
}
];

21
hugo.testing.yml Normal file
View File

@ -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

View File

@ -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

View File

@ -1,13 +1,3 @@
<!-- START COMPONENT AND JS BUNDLING REFACTOR
Eventually, all site-specific JavaScript and external JS
dependencies will be bundled in main.js
-->
<!-- Legacy: keep jquery here until component refactor is for scripts in footer.bundle.js that still require it. -->
{{ $jquery := resources.Get "js/jquery-3.5.0.min.js" }}
{{ $headerjs := slice $jquery | resources.Concat "js/header.bundle.js" | resources.Fingerprint }}
<script type="text/javascript" src="{{ $headerjs.RelPermalink }}"></script>
<!-- $productPathData here is buggy - it might not return the current page path due to the context in which .RelPermalink is called -->
{{ $productPathData := findRE "[^/]+.*?" .RelPermalink }}
{{ $product := index $productPathData 0 }}
@ -23,7 +13,7 @@
{{ $products := .Site.Data.products }}
{{ $influxdb_urls := .Site.Data.influxdb_urls }}
<!-- Build main.js -->
{{ with resources.Get "js/main.js" }}
{{ with resources.Get "js/index.js" }}
{{ $opts := dict
"minify" hugo.IsProduction
"sourceMap" (cond hugo.IsProduction "" "external")

View File

@ -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}'

View File

@ -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"
},

View File

@ -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"