diff --git a/.github/workflows/check-links.yml b/.github/workflows/check-links.yml
index e0dab4ba3..98063e9d6 100644
--- a/.github/workflows/check-links.yml
+++ b/.github/workflows/check-links.yml
@@ -97,19 +97,29 @@ jobs:
fi
- name: Suggest fixes as review comments
- # One suggested change per added line with a fix. Suggestions already on
- # the PR (same file, line, and text) are not posted again.
- if: steps.check.outputs.broken == 'true' && github.event.pull_request.head.repo.full_name == github.repository
+ # One suggested change per finding with a fix. Suggestions already on the
+ # PR (same file, line, and link) are not posted again; suggestions for
+ # findings that are gone are deleted. GitHub sets line to null on comments
+ # it could not carry to the new revision, so those are deleted too.
+ if: github.event.pull_request.head.repo.full_name == github.repository
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
+ key='(.path + ":" + (.line | tostring) + ":" + (.body | split("\n")[0]))'
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \
- --jq '.[] | {path, line, body}' | jq -s . > "$RUNNER_TEMP/posted.json"
+ --jq ".[] | select(.body | startswith(\"'
+ existing_comment=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
+ --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -n 1)
+
+ # Comment only when there is something to report, or an earlier report to resolve
+ if [ "$BROKEN" = true ]; then
+ { echo "$marker"; cat "$RUNNER_TEMP/report.md"; } > "$RUNNER_TEMP/comment.md"
+ elif [ -n "$existing_comment" ]; then
+ printf '%s\n### ā
The redirects an earlier revision of this PR broke are fixed\n' \
+ "$marker" > "$RUNNER_TEMP/comment.md"
+ else
+ exit 0
+ fi
+
+ if [ -n "$existing_comment" ]; then
+ gh api --method PATCH "repos/$GITHUB_REPOSITORY/issues/comments/$existing_comment" \
+ --field body=@"$RUNNER_TEMP/comment.md"
+ else
+ gh pr comment "$PR_NUMBER" --body-file "$RUNNER_TEMP/comment.md"
+ fi
+
+ - name: Suggest fixes as review comments
+ # One suggested change per fixable entry this PR added. Suggestions
+ # already on the PR (same lines and entry) are not posted again;
+ # suggestions for findings that are gone are deleted. GitHub sets line
+ # to null on comments it could not carry to the new revision, so those
+ # are deleted too.
+ if: github.event.pull_request.head.repo.full_name == github.repository
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ key='(.path + ":" + (.line | tostring) + ":" + (.body | split("\n")[0]))'
+ gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" --paginate \
+ --jq ".[] | select(.body | startswith(\"`,
+ `Link: \`${url}\``,
+ `Problem: ${error}`,
+ `Fix: \`${fix}\``,
+ '````suggestion',
+ source.split(url).join(fix),
+ '````'
+ ];
+ return { path: file, line, side: 'RIGHT', body: body.join('\n') };
});
- return {
- event: 'COMMENT',
- body: 'Suggested fixes for the links this PR adds; details in the check-links comment.',
- comments
- };
+ return { event: 'COMMENT', body: '', comments };
}
const FORMATTERS = {
diff --git a/dev/check-redirects.mjs b/dev/check-redirects.mjs
new file mode 100644
index 000000000..f13831db8
--- /dev/null
+++ b/dev/check-redirects.mjs
@@ -0,0 +1,496 @@
+#!/usr/bin/env node
+
+/**
+ * Checks redirects in src/data/redirects.ts.
+ *
+ * Redirects exist so external traffic to an old URL still reaches a page, so
+ * each one must be correct. Checks, for every entry:
+ * - the source does not shadow an existing page (the middleware would redirect
+ * visitors away from a page that exists)
+ * - the source has no #fragment: browsers never send fragments, so such an
+ * entry can never match
+ * - the source has no earlier entry: the middleware uses the first match only
+ * - neither path starts with /docs: the middleware strips that prefix from
+ * requests and adds it to destinations
+ * - the destination is a page, not another redirect
+ * - the destination page exists under docs/ (or is a file under public/)
+ * - when the destination has a #fragment, the heading exists on that page
+ *
+ * External (http) destinations are not checked.
+ *
+ * Usage: node dev/check-redirects.mjs [options]
+ * --root
Repository to check (default: this repository)
+ * --format Output as text (default), json, or markdown
+ * --baseline Only report findings absent from this JSON file
+ * (produced by --format json on another revision)
+ * --link-base Markdown output links each line to
+ * /src/data/redirects.ts, e.g.
+ * https://github.com/sourcegraph/docs/blob/
+ * --diff `git diff` output; with --review, only entries this
+ * diff added get a suggested change
+ * --review Write a pull request review with one suggested
+ * change per fixable finding to this JSON file
+ *
+ * Exits 1 when any finding is reported.
+ */
+
+import fs from 'fs';
+import path from 'path';
+import vm from 'vm';
+import {fileURLToPath} from 'url';
+import {extractHeadings, listFiles, routeFor} from './check-links.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+const args = process.argv.slice(2);
+const ROOT_DIR = path.resolve(flagValue('--root') ?? path.dirname(__dirname));
+const FORMAT = flagValue('--format') ?? 'text';
+const BASELINE_FILE = flagValue('--baseline');
+const LINK_BASE = flagValue('--link-base')?.replace(/\/$/, '');
+const DIFF_FILE = flagValue('--diff');
+const REVIEW_FILE = flagValue('--review');
+
+const REDIRECTS_PATH = 'src/data/redirects.ts';
+const REDIRECTS_FILE = path.join(ROOT_DIR, REDIRECTS_PATH);
+const CONSTANTS_FILE = path.join(ROOT_DIR, 'src/data/constants.ts');
+const DOCS_DIR = path.join(ROOT_DIR, 'docs');
+const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
+// Report sections, most urgent first: a shadowed page is unreachable today.
+// `fix` teaches the author what a correct entry looks like, where it applies.
+const PROBLEM = {
+ shadowsPage: {
+ heading: 'Source overshadows a docs page that exists',
+ fix:
+ "Redirects take precedence over pages, so visitors to that page's URL are redirected away from it. " +
+ 'Update or remove the redirect or the page to remove the conflict.'
+ },
+ fragmentSource: {
+ heading: 'Source has a #fragment, so this redirect can never match',
+ fix:
+ 'Use the page path alone as the source. #fragments are processed in the browser, so browsers ' +
+ 'never send them to web servers.\n\n' +
+ 'If the redirect destination has a #fragment, it takes precedence, otherwise if the customer ' +
+ "clicked a link which has a #fragment, it'll be kept and tried on the destination page."
+ },
+ docsPrefix: {
+ heading: 'Source or destination starts with /docs',
+ fix:
+ 'Write paths without the /docs prefix. The site removes /docs from the requested URL before ' +
+ 'matching sources, and adds it back in front of the destination, so a /docs/... source never ' +
+ 'matches and a /docs/... destination lands on /docs/docs/....'
+ },
+ duplicateSource: {
+ heading:
+ 'Source already has an earlier entry, so this one is never used',
+ fix: 'Only the first entry for a source matches. Update that entry instead of adding another.'
+ },
+ chained: {
+ heading: 'Destination is another redirect',
+ fix:
+ "Chained redirects cost the customer's browser a round trip, slow down their page load time, " +
+ 'and frustrate them. They also make the redirects file impossible to maintain, and make it too ' +
+ "easy to create redirect loops. Change the rule's destination to the final destination."
+ },
+ missingPage: {
+ heading: 'Destination page does not exist',
+ fix:
+ 'Set the redirect destination to the page that replaced it, or remove the rule if there is no replacement page; ' +
+ "visitors then get our fancy 404 page, with links they can click to find where they're trying " +
+ 'to go, and the search bar.'
+ },
+ missingHeading: {
+ heading: 'Destination heading does not exist',
+ fix: "Use the heading's correct anchor, or drop the #fragment to land the customer at the top of the page."
+ }
+};
+
+function flagValue(name) {
+ const index = args.indexOf(name);
+ return index === -1 ? undefined : args[index + 1];
+}
+
+// Load redirects.ts without a TypeScript toolchain. The file is plain data
+// plus one import, so strip the module syntax and evaluate it.
+// Returns [{ source, destination, line }].
+function loadRedirects() {
+ const source = fs.readFileSync(REDIRECTS_FILE, 'utf-8');
+ const constants = fs.readFileSync(CONSTANTS_FILE, 'utf-8');
+ const rssUrl =
+ constants.match(
+ /TECHNICAL_CHANGELOG_RSS_URL\s*=\s*['"]([^'"]+)['"]/
+ )?.[1] ?? '';
+
+ const script = source
+ .replace(/^import .*$/gm, '')
+ .replace(/^export const /gm, 'const ')
+ .replace(/module\.exports\s*=\s*\{[\s\S]*?\};?/g, '');
+
+ const sandbox = {TECHNICAL_CHANGELOG_RSS_URL: rssUrl};
+ vm.runInNewContext(`${script}\nresult = updatedRedirectsData;`, sandbox);
+
+ // Line numbers of each entry, for the report and suggested changes: the
+ // `source:` line, plus the `{` and `}` lines around it. Entries are written
+ // one `source:` per line; if that assumption fails, omit line numbers.
+ const lines = source.split('\n');
+ const arrayEnd = lines.findIndex(line => /^\];?\s*$/.test(line));
+ const positions = [];
+ lines.slice(0, arrayEnd).forEach((line, index) => {
+ if (!/^\s*source:/.test(line)) return;
+ let start = index;
+ while (start > 0 && !/^\s*\{\s*$/.test(lines[start])) start--;
+ let end = index;
+ while (end < arrayEnd && !/^\s*\},?\s*$/.test(lines[end])) end++;
+ positions.push({line: index + 1, startLine: start + 1, endLine: end + 1});
+ });
+ const havePositions = positions.length === sandbox.result.length;
+
+ return sandbox.result.map((redirect, index) => ({
+ source: redirect.source,
+ destination: redirect.destination,
+ ...(havePositions ? positions[index] : {})
+ }));
+}
+
+// Site route -> Set of anchors on that page. When foo.mdx and foo/index.mdx
+// both exist the first (sorted) file owns the route, as in check-links.mjs.
+function buildHeadingsByRoute() {
+ const headingsByRoute = new Map();
+ for (const file of listFiles(DOCS_DIR, ['.mdx'])) {
+ const route = routeFor(file);
+ if (headingsByRoute.has(route)) continue;
+ headingsByRoute.set(
+ route,
+ extractHeadings(fs.readFileSync(path.join(DOCS_DIR, file), 'utf-8'))
+ );
+ }
+ return headingsByRoute;
+}
+
+// '/foo/bar/?x=1#baz' -> { pathname: '/foo/bar', fragment: 'baz' }
+function splitUrl(url) {
+ const [pathAndQuery, fragment = ''] = url.split('#');
+ const pathname = pathAndQuery.split('?')[0].replace(/\/+$/, '') || '/';
+ return {pathname, fragment: decodeURIComponent(fragment)};
+}
+
+function isPublicFile(pathname) {
+ const fullPath = path.join(PUBLIC_DIR, pathname);
+ return fullPath.startsWith(PUBLIC_DIR) && fs.existsSync(fullPath);
+}
+
+function isExternal(url) {
+ return /^https?:\/\//.test(url);
+}
+
+// Every incorrect redirect: [{ source, destination, line, problem, detail?, fix? }].
+// `fix` is the entry that should replace this one, or {remove: true}, where the
+// fix is mechanical; a shadowed page or missing destination needs a human.
+function findBrokenRedirects(redirects, headingsByRoute) {
+ const findings = [];
+ const firstBySource = new Map();
+ for (const redirect of redirects) {
+ if (!firstBySource.has(redirect.source))
+ firstBySource.set(redirect.source, redirect);
+ }
+ const report = (redirect, problem, {detail, fix} = {}) =>
+ findings.push({...redirect, problem: problem.heading, detail, fix});
+ const withoutFragment = url => url.split('#')[0];
+ const withoutDocsPrefix = url => url.replace(/^\/docs(?=\/)/, '');
+ const isRedirect = pathname =>
+ firstBySource.has(pathname) && !headingsByRoute.has(pathname);
+
+ for (const redirect of redirects) {
+ const source = splitUrl(redirect.source);
+ if (source.fragment) {
+ // Another entry may already cover the source without its fragment
+ const fix = firstBySource.has(withoutFragment(redirect.source))
+ ? {remove: true}
+ : {source: withoutFragment(redirect.source), destination: redirect.destination};
+ report(redirect, PROBLEM.fragmentSource, {fix});
+ continue;
+ }
+ if (firstBySource.get(redirect.source) !== redirect) {
+ report(redirect, PROBLEM.duplicateSource, {fix: {remove: true}});
+ continue;
+ }
+ if (headingsByRoute.has(source.pathname)) {
+ report(redirect, PROBLEM.shadowsPage);
+ }
+ if (
+ source.pathname.startsWith('/docs/') ||
+ redirect.destination.startsWith('/docs/')
+ ) {
+ report(redirect, PROBLEM.docsPrefix, {
+ fix: {
+ source: withoutDocsPrefix(redirect.source),
+ destination: withoutDocsPrefix(redirect.destination)
+ }
+ });
+ continue;
+ }
+ if (isExternal(redirect.destination)) continue;
+
+ const destination = splitUrl(redirect.destination);
+ if (isRedirect(destination.pathname)) {
+ // The fix names the final destination; only a loop needs `detail`
+ const final = finalDestination(destination.pathname);
+ report(
+ redirect,
+ PROBLEM.chained,
+ final
+ ? {fix: {source: redirect.source, destination: final}}
+ : {detail: 'none, redirect loop'}
+ );
+ continue;
+ }
+ const headings = headingsByRoute.get(destination.pathname);
+ if (!headings) {
+ if (!isPublicFile(destination.pathname)) {
+ report(redirect, PROBLEM.missingPage);
+ }
+ continue;
+ }
+ if (destination.fragment && !headings.has(destination.fragment)) {
+ report(redirect, PROBLEM.missingHeading, {
+ fix: {source: redirect.source, destination: destination.pathname}
+ });
+ }
+ }
+
+ // Where a visitor to `pathname` finally lands; undefined for a redirect loop
+ function finalDestination(pathname) {
+ const visited = new Set();
+ while (isRedirect(pathname) && !visited.has(pathname)) {
+ visited.add(pathname);
+ pathname = firstBySource.get(pathname).destination;
+ if (isExternal(pathname)) return pathname;
+ pathname = splitUrl(pathname).pathname;
+ }
+ return visited.has(pathname) ? undefined : pathname;
+ }
+ return findings;
+}
+
+// Line numbers are left out so an entry that only moved is not a new finding
+function findingKey(finding) {
+ return `${finding.source}\u0000${finding.destination}\u0000${finding.problem}`;
+}
+
+function withoutBaseline(findings, baselineFile) {
+ const baseline = new Set(
+ JSON.parse(fs.readFileSync(baselineFile, 'utf-8')).map(findingKey)
+ );
+ return findings.filter(finding => !baseline.has(findingKey(finding)));
+}
+
+// 'remove this entry', or which of source and destination to change to what
+function describeFix({source, destination, fix}) {
+ if (fix.remove) return 'remove this entry';
+ const changes = [
+ ...(fix.source !== source ? [`the source to \`${fix.source}\``] : []),
+ ...(fix.destination !== destination
+ ? [`the destination to \`${fix.destination}\``]
+ : [])
+ ];
+ return `change ${changes.join(' and ')}`;
+}
+
+function formatText(findings) {
+ const scope = BASELINE_FILE ? 'broken by this change' : 'broken';
+ if (findings.length === 0) {
+ return `ā
No redirects ${scope}\n`;
+ }
+ const lines = [`ā ${findings.length} redirect(s) ${scope}:`, ''];
+ for (const finding of findings) {
+ const {source, destination, line, problem, detail, fix} = finding;
+ lines.push(
+ ` ${REDIRECTS_PATH}${line ? `:${line}` : ''}`,
+ ` ${source} -> ${destination}`,
+ ` ${problem}${detail ? ` (final destination: ${detail})` : ''}`,
+ ...(fix ? [` Fix: ${describeFix(finding).replaceAll('`', '')}`] : []),
+ ''
+ );
+ }
+ return lines.join('\n');
+}
+
+function linkTo(text, url) {
+ return url ? `[${text}](${url})` : text;
+}
+
+// Map of problem heading -> its findings in line order, sections in PROBLEM order
+function groupByProblem(findings) {
+ const groups = new Map(
+ Object.values(PROBLEM).map(({heading}) => [heading, []])
+ );
+ for (const finding of findings) groups.get(finding.problem).push(finding);
+ for (const [problem, entries] of groups) {
+ if (entries.length === 0) groups.delete(problem);
+ else entries.sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
+ }
+ return groups;
+}
+
+// Body for a pull request comment
+function formatMarkdown(findings) {
+ if (findings.length === 0) {
+ return '### ā
This PR breaks no redirects\n';
+ }
+
+ // ?plain=1 opens GitHub's code view, where #L anchors work
+ const fileUrl = LINK_BASE && `${LINK_BASE}/${REDIRECTS_PATH}?plain=1`;
+ const lines = [
+ `### ā This PR breaks ${findings.length} redirect(s)`,
+ '',
+ 'Redirects are used so inbound traffic from external sources (links inside old versions of ' +
+ 'our product, bookmarks, search results, etc.) to old doc pages still reaches a relevant page.',
+ '',
+ 'A correct entry maps the old page path, exactly as the browser requests it, ' +
+ 'straight to a page that exists today, with an optional #heading that exists on the destination page:',
+ '',
+ '```ts',
+ '{',
+ "\tsource: '/old/section/page',",
+ "\tdestination: '/new/section/page#heading-slug'",
+ '},',
+ '```',
+ '',
+ 'Each section below explains how to fix the entries listed under it.',
+ '',
+ 'Do not use redirects for broken internal links, internal links must be fixed ' +
+ 'properly to tame the tech debt snowball no one wants to deal with; the ' +
+ '"Check links" PR check comment lists the links this PR broke, if any.',
+ '',
+ linkTo(`**\`${REDIRECTS_PATH}\`**`, fileUrl)
+ ];
+ // One section per problem with its fix. Each entry is shown as it appears
+ // in the redirects file, so it is easy to find there.
+ const fixes = new Map(
+ Object.values(PROBLEM).map(({heading, fix}) => [heading, fix])
+ );
+ for (const [problem, entries] of groupByProblem(findings)) {
+ lines.push('', `#### ${problem}`, '', fixes.get(problem), '');
+ for (const finding of entries) {
+ const {source, destination, line, detail, fix} = finding;
+ const where = line
+ ? linkTo(`line ${line}`, fileUrl && `${fileUrl}#L${line}`)
+ : 'entry';
+ lines.push(
+ `- ${where}`,
+ ' ```ts',
+ ` source: '${source}',`,
+ ` destination: '${destination}'`,
+ ...(detail ? [` final destination: ${detail}`] : []),
+ ' ```',
+ ...(fix ? [` Fix: ${describeFix(finding)}`] : [])
+ );
+ }
+ }
+ lines.push(
+ '',
+ 'Reproduce locally with `node dev/check-redirects.mjs`'
+ );
+ return lines.join('\n') + '\n';
+}
+
+// Line numbers `git diff` output added to redirects.ts
+function addedLines(diffFile) {
+ const added = new Set();
+ let inRedirects = false;
+ let lineNumber;
+ for (const line of fs.readFileSync(diffFile, 'utf-8').split('\n')) {
+ if (line.startsWith('+++ ')) {
+ inRedirects = line === `+++ b/${REDIRECTS_PATH}`;
+ } else if (line.startsWith('@@ ')) {
+ lineNumber = Number(line.match(/^@@ -\S+ \+(\d+)/)[1]);
+ } else if (inRedirects && line.startsWith('+')) {
+ added.add(lineNumber++);
+ } else if (inRedirects && line.startsWith(' ')) {
+ lineNumber++;
+ }
+ }
+ return added;
+}
+
+// First line of a review comment, so the workflow can match the comments it
+// posted earlier to the findings still present and delete the rest
+const REVIEW_MARKER = '`,
+ `Problem: ${problem}`,
+ `Fix: ${describeFix(finding)}`,
+ '````suggestion',
+ ...entry,
+ '````'
+ ];
+ return {
+ path: REDIRECTS_PATH,
+ ...(startLine !== endLine && {start_line: startLine, start_side: 'RIGHT'}),
+ line: endLine,
+ side: 'RIGHT',
+ body: body.join('\n')
+ };
+ });
+ return {event: 'COMMENT', body: '', comments};
+}
+
+const FORMATTERS = {
+ text: formatText,
+ json: findings => JSON.stringify(findings, null, '\t') + '\n',
+ markdown: formatMarkdown
+};
+
+function main() {
+ const format = FORMATTERS[FORMAT];
+ if (!format) {
+ throw new Error(
+ `Unknown --format "${FORMAT}"; use text, json, or markdown`
+ );
+ }
+
+ let findings = findBrokenRedirects(loadRedirects(), buildHeadingsByRoute());
+ if (BASELINE_FILE) {
+ findings = withoutBaseline(findings, BASELINE_FILE);
+ }
+
+ if (REVIEW_FILE) {
+ const added = DIFF_FILE ? addedLines(DIFF_FILE) : new Set();
+ fs.writeFileSync(
+ REVIEW_FILE,
+ JSON.stringify(reviewRequest(findings, added), null, '\t') + '\n'
+ );
+ }
+
+ // Not process.exit(): that can truncate stdout when it is a pipe
+ process.stdout.write(format(findings));
+ process.exitCode = findings.length === 0 ? 0 : 1;
+}
+
+main();
diff --git a/dev/check-spelling.mjs b/dev/check-spelling.mjs
index 733949636..cf07ab1c9 100644
--- a/dev/check-spelling.mjs
+++ b/dev/check-spelling.mjs
@@ -1,7 +1,8 @@
#!/usr/bin/env node
/**
- * Reports CSpell findings on lines added by a Git diff.
+ * Reports CSpell findings on lines added by a Git diff, and dictionary entries
+ * added out of alphabetical order.
*
* Usage: node dev/check-spelling.mjs --base [--format text|json]
*
@@ -10,6 +11,7 @@
*/
import {execFileSync, spawnSync} from 'child_process';
+import {readFileSync} from 'fs';
import path from 'path';
import {fileURLToPath} from 'url';
@@ -87,11 +89,51 @@ function runCSpell(files) {
column: issue.col,
word: issue.text,
suggestions: issue.suggestions?.slice(0, 3) ?? [],
- text: issue.line.text.replace(/\r?\n$/, ''),
- context: issue.context?.text.trim() ?? issue.line.text.trim()
+ text: issue.line.text.replace(/\r?\n$/, '')
}));
}
+// Entries in the dictionary files must be sorted, so duplicates stand out and
+// merges are clean. Sorted the way CSpell matches: case- and accent-insensitive.
+// Blank lines and comments start a new sorted run, so the lists can be sectioned.
+const DICTIONARY_FILES = ['cspell-allow-list.txt', 'cspell-block-list.txt'];
+const collator = new Intl.Collator('en', {sensitivity: 'base'});
+
+function unsortedDictionaryEntries(files) {
+ const findings = [];
+ for (const file of files.filter(file => DICTIONARY_FILES.includes(file))) {
+ let run = []; // [{word, line}] of the current sorted run
+ readFileSync(file, 'utf8')
+ .split('\n')
+ .forEach((text, index) => {
+ if (/^\s*(#|$)/.test(text)) {
+ run = [];
+ return;
+ }
+ const word = text.replace(/\s*#.*/, '').trim();
+ const line = index + 1;
+ const belongsBefore = run.find(
+ entry => collator.compare(word, entry.word) < 0
+ );
+ if (belongsBefore) {
+ findings.push({
+ file,
+ line,
+ column: 1,
+ word,
+ suggestions: [],
+ text,
+ message: `\`${word}\` is out of alphabetical order: move it above \`${belongsBefore.word}\``,
+ relatedLine: belongsBefore.line // rendered as a link after the message
+ });
+ } else {
+ run.push({word, line});
+ }
+ });
+ }
+ return findings;
+}
+
function findingsOnAddedLines(ranges, issues) {
return issues.filter(issue =>
(ranges.get(issue.file) ?? []).some(
@@ -102,16 +144,17 @@ function findingsOnAddedLines(ranges, issues) {
function formatText(findings) {
if (findings.length === 0) {
- return 'No spelling errors found in added lines.\n';
+ return 'No issues found in added lines.\n';
}
const lines = [
- `Found ${findings.length} spelling error(s) in added lines:`
+ `Found ${findings.length} issue(s) in added lines:`
];
- for (const finding of findings) {
- lines.push(
- `${finding.file}:${finding.line}:${finding.column} - Unknown word (${finding.word})`
- );
+ for (const {file, line, column, word, message, relatedLine} of findings) {
+ const detail = message
+ ? `${message}${relatedLine ? ` on line ${relatedLine}` : ''}`
+ : `Unknown word (${word})`;
+ lines.push(`${file}:${line}:${column} - ${detail}`);
}
return lines.join('\n') + '\n';
}
@@ -125,10 +168,11 @@ async function main() {
}
const ranges = addedLineRanges(BASE);
- const findings = findingsOnAddedLines(
- ranges,
- runCSpell([...ranges.keys()])
- );
+ const files = [...ranges.keys()];
+ const findings = findingsOnAddedLines(ranges, [
+ ...runCSpell(files),
+ ...unsortedDictionaryEntries(files)
+ ]);
process.stdout.write(
FORMAT === 'json'
? JSON.stringify(findings, null, '\t') + '\n'
diff --git a/dev/post-spelling-review.mjs b/dev/post-spelling-review.mjs
index 38d18a88e..94eacb399 100644
--- a/dev/post-spelling-review.mjs
+++ b/dev/post-spelling-review.mjs
@@ -100,10 +100,35 @@ function groupByFile(findings) {
return grouped;
}
+// Source view (?plain=1, so Markdown is not rendered) with the word highlighted
+function sourceLink(finding) {
+ const {file, line, column, word} = finding;
+ const end = column + word.length;
+ return `https://github.com/${REPOSITORY}/blob/${HEAD_REF}/${file}?plain=1#L${line}C${column}-L${line}C${end}`;
+}
+
+// A non-spelling finding's own message, e.g. an unsorted dictionary entry,
+// pointing at its `relatedLine` when it has one
+function messageText(finding) {
+ const {file, message, relatedLine} = finding;
+ if (!relatedLine) return `${message}.`;
+ const url = `https://github.com/${REPOSITORY}/blob/${HEAD_REF}/${file}?plain=1#L${relatedLine}`;
+ return `${message} on [line ${relatedLine}](${url}).`;
+}
+
+// `word` ā `suggestion`, or the finding's own message
+function summaryItem(finding) {
+ if (finding.message) {
+ return messageText(finding);
+ }
+ const suggestion = bestSuggestion(finding);
+ return `\`${finding.word}\`${suggestion ? ` ā \`${suggestion}\`` : ''}`;
+}
+
function summaryBody(findings) {
const lines = [
SUMMARY_MARKER,
- `### ā ļø CSpell found ${findings.length} spelling error(s) in this PR`,
+ `### ā ļø Spell check found ${findings.length} issue(s) in this PR`,
'',
'Only findings on lines added by this PR are shown.',
...(findings.length > MAX_INLINE_COMMENTS
@@ -115,10 +140,11 @@ function summaryBody(findings) {
];
for (const [file, fileFindings] of groupByFile(findings)) {
lines.push(`**\`${file}\`**`);
- for (const {line, column, word, context} of fileFindings) {
- const excerpt = context.replaceAll('`', "'").slice(0, 160);
+ for (const finding of fileFindings) {
+ const {line, column} = finding;
lines.push(
- `- line ${line}, column ${column}: \`${word}\` ā \`${excerpt}\``
+ `- [line ${line}, column ${column}](${sourceLink(finding)})`,
+ ` - ${summaryItem(finding)}`
);
}
lines.push('');
@@ -207,13 +233,15 @@ function suggestionBlock(finding) {
}
function inlineBody(finding) {
- return [
- `${INLINE_MARKER} ${finding.word} -->`,
- `\`${finding.word}\` is not in the dictionary.`,
- '',
- ...suggestionBlock(finding),
- `Please correct the spelling, or add the word to ${ALLOW_LIST_LINK} if it is correct.`
- ].join('\n');
+ const explanation = finding.message
+ ? [messageText(finding)]
+ : [
+ `\`${finding.word}\` is not in the dictionary.`,
+ '',
+ ...suggestionBlock(finding),
+ `Please correct the spelling, or add the word to ${ALLOW_LIST_LINK} if it is correct.`
+ ];
+ return [`${INLINE_MARKER} ${finding.word} -->`, ...explanation].join('\n');
}
async function syncInlineComments(findings) {
diff --git a/docs/admin/markdown.mdx b/docs/admin/markdown.mdx
deleted file mode 100644
index 3308e167f..000000000
--- a/docs/admin/markdown.mdx
+++ /dev/null
@@ -1,3 +0,0 @@
-# Sourcegraph-flavored Markdown
-
-Sourcegraph uses [GitHub Flavored Markdown (GFM)](https://github.github.com/gfm/) anywhere Markdown is rendered. We like this [Markdown cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) for reference.
diff --git a/docs/admin/tls_ssl.mdx b/docs/admin/tls_ssl.mdx
new file mode 100644
index 000000000..11c9a2c37
--- /dev/null
+++ b/docs/admin/tls_ssl.mdx
@@ -0,0 +1,5 @@
+---
+title: TLS test page
+---
+
+# TLS test page
diff --git a/docs/batch-changes/server-side.mdx b/docs/batch-changes/server-side.mdx
index c301fa1d8..537b21f59 100644
--- a/docs/batch-changes/server-side.mdx
+++ b/docs/batch-changes/server-side.mdx
@@ -37,7 +37,7 @@ Running batch changes server-side has been tested to run a simple **45K changese
By default, docker on mac will build docker images for `linux/arm64`, which will result in errors when running server-side because executors provide `linux/amd64` hosts. If you're creating your own images to run in batch changes, this can be a problem. Use the `--platform linux/amd64` flag with `docker build` to build images compatible with the server-side host.
-## Using file mounts with server-side execution
+## Using file mounts with SSBC
Running a batch spec server-side with file mounts is currently only
diff --git a/docs/code-search/features.mdx b/docs/code-search/features.mdx
index b86a4d8f3..7a1b47dd5 100644
--- a/docs/code-search/features.mdx
+++ b/docs/code-search/features.mdx
@@ -45,7 +45,7 @@ Searching over commit messages is supported in Sourcegraph by adding `type:commi
See our [query syntax](/code-search/queries#diff-and-commit-searches-only) documentation for a comprehensive list of supported parameters.
-## Symbol search
+## Symbol lookup
Searching for symbols makes it easier to find specific functions, variables, and more. Use the `type:symbol` filter to search for symbol results. Symbol results also appear in typeahead suggestions, so you can jump directly to symbols by name. When on an [indexed](/admin/search#indexed-search) commit, it uses Zoekt. Otherwise it uses the [symbols service](/code-search/types/symbol)
@@ -148,3 +148,9 @@ This file picker is useful when comparing branches with thousands of changed fil
- When viewing a file or directory, press the `y` key to expand the URL to its canonical form (with the full 40-character Git commit SHA).
- To share a link to multi-line range in a file, click on the starting line number and shift-click on the ending line number (in the left-hand gutter).
+
+Test links for the check-links workflow (will be reverted): [site config](https://sourcegraph.com/docs/admin/config/site-config), [search](//www.sourcegraph.com/docs/code-search/), [moved page](http://docs.sourcegraph.com/admin/http_https_configuration#sourcegraph-via-docker-compose-caddy-2), [dead external](https://github.com/sourcegraph/docs/blob/main/this-file-does-not-exist.md), [live external](https://github.com/sourcegraph/docs).
+
+More test links, none of which the check can fix for you: [missing page](/code-search/no-such-page), [missing heading](/code-search/features#no-such-heading), [wrong case](/Code-Search/queries).
+
+Test paragraph for the spell check workflow (will be reverted): Sourcegraph indexs every repositry acros your organization, so seach results are alwasy fresh. It keeps compatability with the databse layer, and the exector runs each batch spec in its own contiainer.
diff --git a/src/data/redirects.ts b/src/data/redirects.ts
index c1a548241..2652334e0 100644
--- a/src/data/redirects.ts
+++ b/src/data/redirects.ts
@@ -1,6 +1,22 @@
import {TECHNICAL_CHANGELOG_RSS_URL} from './constants';
const redirectsData = [
+ {
+ source: "/docs/old-prefixed",
+ destination: "/code-ownership"
+ },
+ {
+ source: "/old-chain",
+ destination: "/admin/http_https_configuration"
+ },
+ {
+ source: "/old-ownership",
+ destination: "/code-ownershp"
+ },
+ {
+ source: "/old-ownership#anchor",
+ destination: "/code-ownership"
+ },
{
source: '/integration/img/disable_extension.png',
destination: '/integration/img/disable-extension.png'
@@ -5896,6 +5912,10 @@ const redirectsData = [
source: '/code-search/how-to/create-search-context-graphql',
destination: '/api'
},
+ {
+ source: "/admin/http_https_configuration",
+ destination: "/self-hosted/http-https-configuration"
+ }
];
const updatedRedirectsData = redirectsData.map(redirect => {