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(/&amp;/g, '&')
-        .replace(/&#39;/g, "'")
-        .replace(/&quot;/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(/&amp;/g, '&')
+        .replace(/&#39;/g, "'")
+        .replace(/&quot;/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