From 360515e9460f3f21be47ec1024f249b7e787f664 Mon Sep 17 00:00:00 2001 From: Nateowami Date: Tue, 4 Feb 2025 16:29:59 -0500 Subject: [PATCH] Add script to check if all external links are valid (#2983) --- scripts/check_external_urls.mts | 83 +++++++++++++++++++ .../src/xforge-common/external-url-class.ts | 57 +++++++++++++ .../src/xforge-common/external-url.service.ts | 54 ++---------- 3 files changed, 148 insertions(+), 46 deletions(-) create mode 100755 scripts/check_external_urls.mts create mode 100644 src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url-class.ts diff --git a/scripts/check_external_urls.mts b/scripts/check_external_urls.mts new file mode 100755 index 0000000000..71ae696ccc --- /dev/null +++ b/scripts/check_external_urls.mts @@ -0,0 +1,83 @@ +#!/usr/bin/env -S deno run --allow-net + +// This script checks that all links from the ExternalUrls class are valid. It can optionally take a help URL as an +// argument, so that it can be tested against a non-production copy of the help site. +// +// Suggested usage: +// +// Check against production: +// ./check_external_urls.mts +// +// Check against the Netlify preview build: +// ./check_external_urls.mts https://github-action-preview--scriptureforgehelp.netlify.app +// +// Check against a local copy of the help site: +// ./check_external_urls.mts http://localhost:8000 + +import { ExternalUrls } from "../src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url-class.ts"; +import locales from "../src/SIL.XForge.Scripture/locales.json" with { type: "json" }; + +let helpUrl = "https://help.scriptureforge.org"; + +if (Deno.args.length == 1) { + helpUrl = Deno.args[0]; +} else if (Deno.args.length > 1) { + console.error("Usage: check_external_urls.mts [help URL]"); + Deno.exit(1); +} + +console.log(`Using help URL: ${helpUrl}`); + +const urlsToCheck = new Set(); + +for (const locale of locales) { + if (!locale.helps) continue; + + const externalUrls = new ExternalUrls( + { locale: { helps: locale.helps } }, + { helpUrl, defaultLocaleHelpString: "en" } + ); + + // Enumerate properties where the value is a string + for (const value of Object.values(externalUrls)) { + if (typeof value === "string") { + urlsToCheck.add(value); + } + } + + // Enumerate getters + for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(Object.getPrototypeOf(externalUrls)))) { + if ("get" in descriptor && typeof descriptor.get === "function") { + urlsToCheck.add(descriptor.get.call(externalUrls)); + } + } +} + +const results = await Promise.all( + Array.from(urlsToCheck).map(async url => { + const response = await fetch(url); + return { url, status: response.status, body: await response.text() }; + }) +); + +let failure = false; +for (const result of results) { + if (result.status !== 200) { + failure = true; + console.error(`Error: ${result.url} returned status ${result.status}`); + } else if ( + // Check for anchor in the URL, but skip RoboHelp pages, as they use anchors oddly + result.url.includes("#") && + !result.url.includes("/manual") && + !result.body.includes(`id="${result.url.split("#")[1]}"`) + ) { + failure = true; + console.error(`Error: ${result.url} returned body that does not contain the expected anchor`); + } +} + +if (failure) { + Deno.exit(1); +} else { + console.log("All links are valid"); +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url-class.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url-class.ts new file mode 100644 index 0000000000..9fe4dcf319 --- /dev/null +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url-class.ts @@ -0,0 +1,57 @@ +interface I18nServiceLike { + locale: { helps?: string }; +} + +/** + * This class has been carefully constructed to not import any Angular modules, so it can be imported into other + * scripts. The ExternalUrlService class is an Angular service that extends this and should be used in the Angular + * world. + */ +export class ExternalUrls { + paratext = 'https://paratext.org/'; + transcelerator = 'https://software.sil.org/transcelerator/'; + communitySupport = 'https://community.scripture.software.sil.org/c/scripture-forge/19'; + announcementPage = 'https://software.sil.org/scriptureforge/news/'; + + constructor( + private readonly i18n: I18nServiceLike, + private readonly options: { helpUrl: string; defaultLocaleHelpString: string } + ) {} + + get helps(): string { + const localeUrlPortion = this.i18n.locale.helps || this.options.defaultLocaleHelpString; + return localeUrlPortion === '' ? this.options.helpUrl : `${this.options.helpUrl}/${localeUrlPortion}`; + } + + get manual(): string { + return this.helps + '/manual'; + } + + get autoDrafts(): string { + return this.helps + '/understanding-drafts'; + } + + get rolesHelpPage(): string { + return this.manual + '/#t=concepts%2Froles.htm'; + } + + get transceleratorImportHelpPage(): string { + return this.helps + '/adding-questions#1850d745ac9e8003815fc894b8baaeb7'; + } + + get csvImportHelpPage(): string { + return this.helps + '/adding-questions#1850d745ac9e8085960dd88b648f0c7a'; + } + + get chapterAudioHelpPage(): string { + return this.helps + '/adding-questions#1850d745ac9e80e795f3d611356e74d5'; + } + + get sharingSettingsHelpPage(): string { + return this.helps + '/managing-checkers#1850d745ac9e8097ad4efcb063fc2603'; + } + + get graphite(): string { + return 'https://graphite.sil.org/'; + } +} diff --git a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url.service.ts b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url.service.ts index dc8fbe4a71..696645f982 100644 --- a/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url.service.ts +++ b/src/SIL.XForge.Scripture/ClientApp/src/xforge-common/external-url.service.ts @@ -1,52 +1,14 @@ import { Injectable } from '@angular/core'; import { environment } from '../environments/environment'; +import { ExternalUrls } from './external-url-class'; import { I18nService } from './i18n.service'; -@Injectable({ - providedIn: 'root' -}) -export class ExternalUrlService { - paratext = 'https://paratext.org/'; - transcelerator = 'https://software.sil.org/transcelerator/'; - communitySupport = 'https://community.scripture.software.sil.org/c/scripture-forge/19'; - announcementPage = 'https://software.sil.org/scriptureforge/news/'; - - constructor(private readonly i18n: I18nService) {} - - get helps(): string { - const localeUrlPortion = this.i18n.locale.helps || I18nService.defaultLocale.helps!; - return localeUrlPortion === '' ? environment.helps : `${environment.helps}/${localeUrlPortion}`; - } - - get manual(): string { - return this.helps + '/manual'; - } - - get autoDrafts(): string { - return this.helps + '/understanding-drafts'; - } - - get rolesHelpPage(): string { - return this.manual + '/#t=concepts%2Froles.htm'; - } - - get transceleratorImportHelpPage(): string { - return this.helps + '/adding-questions#1850d745ac9e8003815fc894b8baaeb7'; - } - - get csvImportHelpPage(): string { - return this.helps + '/adding-questions#1850d745ac9e8085960dd88b648f0c7a'; - } - - get chapterAudioHelpPage(): string { - return this.helps + '/adding-questions#1850d745ac9e80e795f3d611356e74d5'; - } - - get sharingSettingsHelpPage(): string { - return this.helps + '/managing-checkers#1850d745ac9e8097ad4efcb063fc2603'; - } - - get graphite(): string { - return 'https://graphite.sil.org/'; +/** + * This service class extends the ExternalUrls class to make it injectable. + */ +@Injectable({ providedIn: 'root' }) +export class ExternalUrlService extends ExternalUrls { + constructor(i18n: I18nService) { + super(i18n, { helpUrl: environment.helps, defaultLocaleHelpString: I18nService.defaultLocale.helps! }); } }