Skip to content
代码片段 群组 项目
未验证 提交 7045c6e9 编辑于 作者: Paul Gascou-Vaillancourt's avatar Paul Gascou-Vaillancourt 提交者: GitLab
浏览文件

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.
上级 ed0bcf9d
No related branches found
No related tags found
无相关合并请求
显示
297 个添加91 个删除
......@@ -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:
......
......@@ -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>
......
......@@ -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>
......
......@@ -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',
});
});
......
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\``,
},
],
},
],
});
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,
};
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',
......
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);
},
};
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]}` : '');
});
},
};
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,
},
});
}
},
});
},
};
0% 加载中 .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册