From 7045c6e9e97e2fbf8fdb895c17b4c13066650329 Mon Sep 17 00:00:00 2001 From: Paul Gascou-Vaillancourt <pgascouvaillancourt@gitlab.com> Date: Tue, 10 Sep 2024 18:59:10 +0000 Subject: [PATCH] Lint against outdated docs links rendered with `HelpPageLink` This adds a local ESLint rule to ensure documentation links rendered with the `HelpPageLink` Vue component are up-to-date. --- .eslintrc.yml | 2 + .../components/container_registry_usage.vue | 5 +- .../group_list_item_prevent_delete_modal.vue | 8 +- ...ups_list_item_prevent_delete_modal_spec.js | 2 +- ...ire_valid_help_page_link_component_spec.js | 100 ++++++++++++++++++ .../eslint-config/eslint-local-rules/index.js | 2 + .../require_valid_help_page_path.js | 85 +-------------- .../utils/eslint_parsing_utils.js | 23 ++++ .../utils/help_page_path_utils.js | 86 +++++++++++++++ ..._require_valid_help_page_link_component.js | 75 +++++++++++++ 10 files changed, 297 insertions(+), 91 deletions(-) create mode 100644 spec/tooling/frontend/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component_spec.js create mode 100644 tooling/eslint-config/eslint-local-rules/utils/eslint_parsing_utils.js create mode 100644 tooling/eslint-config/eslint-local-rules/utils/help_page_path_utils.js create mode 100644 tooling/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component.js diff --git a/.eslintrc.yml b/.eslintrc.yml index 51272c5cf08d5..4ba890e3df4e2 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -181,6 +181,7 @@ rules: - '^router-view$' - '^gl-emoji$' local-rules/require-valid-help-page-path: 'error' + local-rules/vue-require-valid-help-page-link-component: 'error' overrides: - files: - '{,ee/,jh/}spec/frontend*/**/*' @@ -236,6 +237,7 @@ overrides: no-unsanitized/method: off no-unsanitized/property: off local-rules/require-valid-help-page-path: off + local-rules/vue-require-valid-help-page-link-component: off no-restricted-imports: - error - paths: diff --git a/app/assets/javascripts/usage_quotas/storage/components/container_registry_usage.vue b/app/assets/javascripts/usage_quotas/storage/components/container_registry_usage.vue index 605ebee902cbf..8c9c893169e39 100644 --- a/app/assets/javascripts/usage_quotas/storage/components/container_registry_usage.vue +++ b/app/assets/javascripts/usage_quotas/storage/components/container_registry_usage.vue @@ -61,7 +61,10 @@ export default { <number-to-human-size :value="containerRegistrySize" data-testid="total-size-section" /> <storage-type-warning v-if="containerRegistrySizeIsEstimated"> {{ $options.i18n.estimatedWarningTooltip }} - <help-page-link href="user/usage_quotas#delayed-refresh"> + <help-page-link + href="user/packages/container_registry/reduce_container_registry_storage" + anchor="delayed-refresh" + > {{ __('Learn more.') }} </help-page-link> </storage-type-warning> diff --git a/app/assets/javascripts/vue_shared/components/groups_list/group_list_item_prevent_delete_modal.vue b/app/assets/javascripts/vue_shared/components/groups_list/group_list_item_prevent_delete_modal.vue index a946bd2d61594..ecc5cd55e8c52 100644 --- a/app/assets/javascripts/vue_shared/components/groups_list/group_list_item_prevent_delete_modal.vue +++ b/app/assets/javascripts/vue_shared/components/groups_list/group_list_item_prevent_delete_modal.vue @@ -47,11 +47,9 @@ export default { > <gl-sprintf :message="$options.i18n.message"> <template #link="{ content }"> - <help-page-link - href="subscriptions/gitlab_com/index" - anchor="change-the-linked-namespace" - >{{ content }}</help-page-link - > + <help-page-link href="subscriptions/gitlab_com/index" anchor="change-the-linked-group">{{ + content + }}</help-page-link> </template> </gl-sprintf> </gl-modal> diff --git a/spec/frontend/vue_shared/components/groups_list/groups_list_item_prevent_delete_modal_spec.js b/spec/frontend/vue_shared/components/groups_list/groups_list_item_prevent_delete_modal_spec.js index fe16b1d3aadff..d2e661aa756de 100644 --- a/spec/frontend/vue_shared/components/groups_list/groups_list_item_prevent_delete_modal_spec.js +++ b/spec/frontend/vue_shared/components/groups_list/groups_list_item_prevent_delete_modal_spec.js @@ -53,7 +53,7 @@ describe('GroupListItemPreventDeleteModal', () => { ); expect(findGlModal().findComponent(HelpPageLink).props()).toMatchObject({ href: 'subscriptions/gitlab_com/index', - anchor: 'change-the-linked-namespace', + anchor: 'change-the-linked-group', }); }); diff --git a/spec/tooling/frontend/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component_spec.js b/spec/tooling/frontend/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component_spec.js new file mode 100644 index 0000000000000..2138087885d7b --- /dev/null +++ b/spec/tooling/frontend/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component_spec.js @@ -0,0 +1,100 @@ +const path = require('path'); +const { existsSync, readFileSync } = require('fs'); +const { RuleTester } = require('eslint'); +const { marked } = require('marked'); +const rule = require('../../../../../tooling/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component'); + +jest.mock('fs'); +jest.mock('marked'); + +const VALID_PATH = 'this/file/exists'; +const VALID_PATH_MD = 'this/file/exists.md'; +const VALID_PATH_HTML = 'this/file/exists.html'; +const INVALID_PATH = 'this/file/does/not/exist'; +const VALID_ANCHOR = 'valid-anchor'; +const INVALID_ANCHOR = 'invalid-anchor'; + +existsSync.mockImplementation((docsPath) => { + if (docsPath.includes(VALID_PATH)) { + return true; + } + return false; +}); + +readFileSync.mockImplementation(() => ''); + +marked.parse.mockImplementation(() => VALID_ANCHOR); + +const ruleTester = new RuleTester({ + parser: require.resolve('vue-eslint-parser'), + parserOptions: { ecmaVersion: 2020 }, +}); + +function wrapTemplate(content) { + return `<template>${content}</template>`; +} + +function makeComponent(href, anchor = null) { + const anchorProp = anchor ? ` anchor="${anchor}"` : ''; + return wrapTemplate(` + <help-page-link href="${href}"${anchorProp}> + Link to doc + </help-page-link> + `); +} + +ruleTester.run('require-valid-help-page-path', rule, { + valid: [ + makeComponent(VALID_PATH), + makeComponent(VALID_PATH_MD), + makeComponent(VALID_PATH_HTML), + makeComponent(`${VALID_PATH}#${VALID_ANCHOR}`), + makeComponent(`${VALID_PATH_MD}#${VALID_ANCHOR}`), + makeComponent(`${VALID_PATH_HTML}#${VALID_ANCHOR}`), + makeComponent(VALID_PATH, VALID_ANCHOR), + makeComponent(VALID_PATH_MD, VALID_ANCHOR), + makeComponent(VALID_PATH_HTML, VALID_ANCHOR), + ], + invalid: [ + { + code: wrapTemplate('<help-page-link :href="$options.href">Link</help-page-link>'), + errors: [ + { + message: 'The `href` prop must be passed as a string literal.', + }, + ], + }, + { + code: makeComponent(INVALID_PATH), + errors: [ + { + message: `\`${path.join(__dirname, '../../../../../doc', INVALID_PATH, 'index.md')}\` does not exist.`, + }, + ], + }, + { + code: makeComponent(INVALID_PATH), + errors: [ + { + message: `\`${path.join(__dirname, '../../../../../doc', INVALID_PATH, 'index.md')}\` does not exist.`, + }, + ], + }, + { + code: makeComponent(`${VALID_PATH}#${INVALID_ANCHOR}`), + errors: [ + { + message: `\`#${INVALID_ANCHOR}\` not found in \`${path.join(__dirname, '../../../../../doc', VALID_PATH)}.md\``, + }, + ], + }, + { + code: makeComponent(VALID_PATH, INVALID_ANCHOR), + errors: [ + { + message: `\`#${INVALID_ANCHOR}\` not found in \`${path.join(__dirname, '../../../../../doc', VALID_PATH)}.md\``, + }, + ], + }, + ], +}); diff --git a/tooling/eslint-config/eslint-local-rules/index.js b/tooling/eslint-config/eslint-local-rules/index.js index 6ad5215319913..63c0882f15e02 100644 --- a/tooling/eslint-config/eslint-local-rules/index.js +++ b/tooling/eslint-config/eslint-local-rules/index.js @@ -1,5 +1,7 @@ const requireValidHelpPagePath = require('./require_valid_help_page_path'); +const vueRequireValidHelpPageLinkComponent = require('./vue_require_valid_help_page_link_component'); module.exports = { 'require-valid-help-page-path': requireValidHelpPagePath, + 'vue-require-valid-help-page-link-component': vueRequireValidHelpPageLinkComponent, }; diff --git a/tooling/eslint-config/eslint-local-rules/require_valid_help_page_path.js b/tooling/eslint-config/eslint-local-rules/require_valid_help_page_path.js index 2c04a071e9dd1..742670d4af188 100644 --- a/tooling/eslint-config/eslint-local-rules/require_valid_help_page_path.js +++ b/tooling/eslint-config/eslint-local-rules/require_valid_help_page_path.js @@ -1,72 +1,8 @@ -const path = require('path'); const { existsSync, readFileSync } = require('fs'); -const { marked } = require('marked'); +const { getDocsFilePath, getAnchorsInMarkdown } = require('./utils/help_page_path_utils'); -const NON_WORD_RE = /[^\p{L}\p{M}\p{N}\p{Pc}\- \t]/gu; const TYPE_LITERAL = 'Literal'; -const blockLevelRenderer = () => ''; -const inlineLevelRenderer = (token) => token; - -/** - * We use a custom marked rendered to get rid of all the contents we don't need. - * All we care about are the headings' anchors. - * We slugify the titles the same way as in - * https://gitlab.com/gitlab-org/ruby/gems/gitlab_kramdown/-/blob/bbc5ac439a2e6af60cbcce9a157283b2c5b59b38/lib/gitlab_kramdown/parser/header.rb#L78. - */ -marked.use({ - renderer: { - // The below blocks' renderer simply returns an empty string as we don't need them while extracting anchors. - paragraph: blockLevelRenderer, - list: blockLevelRenderer, - table: blockLevelRenderer, - code: blockLevelRenderer, - blockquote: blockLevelRenderer, - hr: blockLevelRenderer, - - // The inline renderer just returns the token's text. This ensures that headings don't contain any HTML. - strong: inlineLevelRenderer, - em: inlineLevelRenderer, - codespan: inlineLevelRenderer, - - /** - * This renders headings as their slugified text which we can then use to get a list of - * anchors in the doc. - * - * @param {string} text - * @returns {string} Slugified heading text - */ - heading(text) { - const slugified = text - .toLowerCase() - .replace(/&/g, '&') - .replace(/'/g, "'") - .replace(/"/g, '"') - .replace(/[ \t]/g, '-') - .replace(NON_WORD_RE, ''); - - return `${slugified}\n`; - }, - }, -}); - -/** - * Infers the Markdown documentation file path from the helper's `path` argument. - * If the path doesn't match a .md file directly, we assume it's a directory containing an index.md file. - * - * @param {string} pathArg The documentation path passed to the helper - * @returns {string} The documentation file path - */ -function getDocsFilePath(pathArg) { - const docsPath = pathArg - .replace(/#.*$/, '') // Remove the anchor if any - .replace(/\.(html|md)$/, ''); // Remove the file extension if any - const docsFilePath = path.join(__dirname, '../../../doc', docsPath); - return existsSync(`${docsFilePath}.md`) - ? `${docsFilePath}.md` - : path.join(docsFilePath, 'index.md'); -} - /** * Extracts the anchor from a given `helpPagePath` call. The anchor can either be passed in the * first argument (eg '/path/to#anchor'). Or as the `anchor` property in the second argument. @@ -87,25 +23,6 @@ function getAnchor(node) { ); } -/** - * Extracts existing anchors in a given Markdown file. - * If some anchors appear multiple times in the document, they are deduplicated by appending an - * incremental index. - * - * @param {string} content The raw content from the Markdown file - * @returns {string[]} The list of anchors - */ -function getAnchorsInMarkdown(content) { - const markdown = marked.parse(content.toString()); - const anchors = markdown.split('\n').filter(Boolean); - const counters = {}; - - return anchors.map((anchor) => { - counters[anchor] = counters[anchor] ? counters[anchor] + 1 : 0; - return anchor + (counters[anchor] > 0 ? `-${counters[anchor]}` : ''); - }); -} - module.exports = { meta: { type: 'problem', diff --git a/tooling/eslint-config/eslint-local-rules/utils/eslint_parsing_utils.js b/tooling/eslint-config/eslint-local-rules/utils/eslint_parsing_utils.js new file mode 100644 index 0000000000000..441c9d7fc8961 --- /dev/null +++ b/tooling/eslint-config/eslint-local-rules/utils/eslint_parsing_utils.js @@ -0,0 +1,23 @@ +module.exports = { + /** + * Register the given visitor to parser services. + * If the parser service of `vue-eslint-parser` was not found, + * this generates a warning. + * + * @param {RuleContext} context The rule context to use parser services. + * @param {Object} templateBodyVisitor The visitor to traverse the template body. + * @param {Object} [scriptVisitor] The visitor to traverse the script. + * @returns {Object} The merged visitor. + */ + defineTemplateBodyVisitor(context, templateBodyVisitor, scriptVisitor) { + if (context.parserServices.defineTemplateBodyVisitor == null) { + context.report({ + loc: { line: 1, column: 0 }, + message: + 'Use the latest vue-eslint-parser. See also https://vuejs.github.io/eslint-plugin-vue/user-guide/#what-is-the-use-the-latest-vue-eslint-parser-error', + }); + return {}; + } + return context.parserServices.defineTemplateBodyVisitor(templateBodyVisitor, scriptVisitor); + }, +}; diff --git a/tooling/eslint-config/eslint-local-rules/utils/help_page_path_utils.js b/tooling/eslint-config/eslint-local-rules/utils/help_page_path_utils.js new file mode 100644 index 0000000000000..1ec092f41beca --- /dev/null +++ b/tooling/eslint-config/eslint-local-rules/utils/help_page_path_utils.js @@ -0,0 +1,86 @@ +const { existsSync } = require('fs'); +const path = require('path'); +const { marked } = require('marked'); + +const NON_WORD_RE = /[^\p{L}\p{M}\p{N}\p{Pc}\- \t]/gu; +const blockLevelRenderer = () => ''; +const inlineLevelRenderer = (token) => token; + +/** + * We use a custom marked rendered to get rid of all the contents we don't need. + * All we care about are the headings' anchors. + * We slugify the titles the same way as in + * https://gitlab.com/gitlab-org/ruby/gems/gitlab_kramdown/-/blob/bbc5ac439a2e6af60cbcce9a157283b2c5b59b38/lib/gitlab_kramdown/parser/header.rb#L78. + */ +marked.use({ + renderer: { + // The below blocks' renderer simply returns an empty string as we don't need them while extracting anchors. + paragraph: blockLevelRenderer, + list: blockLevelRenderer, + table: blockLevelRenderer, + code: blockLevelRenderer, + blockquote: blockLevelRenderer, + hr: blockLevelRenderer, + + // The inline renderer just returns the token's text. This ensures that headings don't contain any HTML. + strong: inlineLevelRenderer, + em: inlineLevelRenderer, + codespan: inlineLevelRenderer, + + /** + * This renders headings as their slugified text which we can then use to get a list of + * anchors in the doc. + * + * @param {string} text + * @returns {string} Slugified heading text + */ + heading(text) { + const slugified = text + .toLowerCase() + .replace(/&/g, '&') + .replace(/'/g, "'") + .replace(/"/g, '"') + .replace(/[ \t]/g, '-') + .replace(NON_WORD_RE, ''); + + return `${slugified}\n`; + }, + }, +}); + +module.exports = { + /** + * Infers the Markdown documentation file path from the helper's `path` argument. + * If the path doesn't match a .md file directly, we assume it's a directory containing an index.md file. + * + * @param {string} pathArg The documentation path passed to the helper + * @returns {string} The documentation file path + */ + getDocsFilePath(pathArg) { + const docsPath = pathArg + .replace(/#.*$/, '') // Remove the anchor if any + .replace(/\.(html|md)$/, ''); // Remove the file extension if any + const docsFilePath = path.join(__dirname, '../../../../doc', docsPath); + return existsSync(`${docsFilePath}.md`) + ? `${docsFilePath}.md` + : path.join(docsFilePath, 'index.md'); + }, + /** + * Extracts existing anchors in a given Markdown file. + * If some anchors appear multiple times in the document, they are deduplicated by appending an + * incremental index. + * + * @param {string} content The raw content from the Markdown file + * @returns {string[]} The list of anchors + */ + getAnchorsInMarkdown(content) { + const markdown = marked.parse(content.toString()); + const anchors = markdown.split('\n').filter(Boolean); + const counters = {}; + + return anchors.map((anchor) => { + counters[anchor] = counters[anchor] ? counters[anchor] + 1 : 0; + return anchor + (counters[anchor] > 0 ? `-${counters[anchor]}` : ''); + }); + }, +}; diff --git a/tooling/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component.js b/tooling/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component.js new file mode 100644 index 0000000000000..267cbd6624644 --- /dev/null +++ b/tooling/eslint-config/eslint-local-rules/vue_require_valid_help_page_link_component.js @@ -0,0 +1,75 @@ +const { existsSync, readFileSync } = require('fs'); +const { defineTemplateBodyVisitor } = require('./utils/eslint_parsing_utils'); +const { getDocsFilePath, getAnchorsInMarkdown } = require('./utils/help_page_path_utils'); + +/** + * Extracts the anchor from a given `HelpPageLink` component. The anchor can either be passed in the + * `href` prop (eg '/path/to#anchor'), or as the `anchor` prop. + * + * @param {VStartTag} node The node from which we are extracting the anchor + * @returns {string?} The extracted anchor + */ +function getAnchor(node) { + if (node.attributes.length === 1) { + return node.attributes[0].value.value.match(/#(.+)$/)?.[1] ?? null; + } + return node.attributes.find((attr) => attr.key.name === 'anchor')?.value?.value ?? null; +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Ensures that `helpPagePath` usages do not break when docs pages get moved around', + }, + }, + create(context) { + return defineTemplateBodyVisitor(context, { + 'VElement[name="help-page-link"] > VStartTag': (node) => { + const hrefAttribute = node.attributes.find((attr) => attr.key.name === 'href'); + + if (!hrefAttribute) { + context.report({ + node, + message: 'The `href` prop must be passed as a string literal.', + }); + return; + } + + const docsFilePath = getDocsFilePath(hrefAttribute.value.value); + + if (!existsSync(docsFilePath)) { + context.report({ + node, + message: '`{{ filePath }}` does not exist.', + data: { + filePath: docsFilePath, + }, + }); + return; + } + + const anchor = getAnchor(node); + + if (!anchor) { + return; + } + + const docsContent = readFileSync(docsFilePath); + const anchors = getAnchorsInMarkdown(docsContent); + + if (!anchors.includes(anchor)) { + context.report({ + node, + message: '`#{{ anchor }}` not found in `{{ filePath }}`', + data: { + anchor, + filePath: docsFilePath, + }, + }); + } + }, + }); + }, +}; -- GitLab