import fs from "node:fs"; import path from "node:path"; import * as util from "./util.js"; import { CONTENT_ROOT } from "../../../libs/env/index.js"; import { KumaThis } from "../environment.js"; import { ONLY_AVAILABLE_IN_ENGLISH } from "../../../libs/l10n/l10n.js"; import { htmlEscape } from "./util.js"; const DUMMY_BASE_URL = "https://example.com"; const _warned = new Map(); // The purpose of this function is to make sure `console.warn` is only called once // per 'macro' per 'href'. // There are some macros that use `smartLink` within themselves and these macros // might be buggy and that's not the fault of the person using the wrapping // macro. And because these might be used over and over and over we don't want // to bombard stdout with warnings more than once. // For example, there are X pages that use the CSS sidebar macro `CSSRef` and it // might contain something like `smartLink(URL + 'oops', ...)` which leads to a // broken link. But that problem lies with the `CSSRef.ejs` macro, which we // don't entirely want to swallow and forget. But we don't want to point this // out on every single page that *uses* that `CSSRef` macro. function warnBrokenFlawByMacro(macro: string, href: string, notes: string) { if (!_warned.has(macro)) { _warned.set(macro, new Set()); } if (!_warned.get(macro).has(href)) { _warned.get(macro).add(href); console.warn(`In ${macro} the smartLink to ${href} is broken! (${notes})`); } } const web = { // Insert a hyperlink. link(uri, text, title, target) { const out = [`", util.htmlEscape(text || uri), ""); return out.join(""); }, // Creates a hyperlink for given document location(href). // e.g /en-US/docs/Web/HTML/Attributes // // For translated content, if the document doesn't exist // then hyperlink to corresponding en-US document is returned. smartLink( this: KumaThis, href: string, title: string | null, content: string | null = null, subpath: string | null = null, basepath: string | null = null, ignoreFlawMacro: string | null = null ) { let flaw; let flawAttribute = ""; const page = this.info.getPageByURL(href); // Get the pathname only (no hash) of the incoming "href" URI. const hrefpath = this.info.getPathname(href); // Save the hash portion, if any, for appending to the "href" attribute later. const hrefhash = new URL(href, DUMMY_BASE_URL).hash; if (page.url) { if (hrefpath.toLowerCase() !== page.url.toLowerCase()) { if (page.url.startsWith(basepath)) { let suggested = page.url.replace(basepath, ""); if ( /\//.test(suggested) && !title && basepath.endsWith("/Web/API/") ) { // This is the exception! When `smartLink` is used from the DOMxRef.ejs // macro, the xref macro notation should use a `.` instead of a `/`. // E.g. `{{domxref("GlobalEventHandlers.onload")}} // because, when displayed we want the HTML to become: // // // GlobalEventHandlers.onload // // // Note the `GlobalEventHandlers.onload` label. // However, some uses of DOMxRef uses a custom title. E.g. // {{domxref("WindowOrWorkerGlobalScope/fetch","fetch()")}} // which needs to become: // // // fetch() // // // So those with titles we ignore. suggested = suggested.replace(/\//g, "."); } if (ignoreFlawMacro) { warnBrokenFlawByMacro( ignoreFlawMacro, href, `redirects to ${page.url}` ); } else { flaw = this.env.recordNonFatalError( "redirected-link", `${hrefpath} redirects to ${page.url}`, { current: subpath, suggested, } ); flawAttribute = ` data-flaw-src="${util.htmlEscape( flaw.macroSource )}"`; } } else { flaw = this.env.recordNonFatalError( "wrong-xref-macro", "Wrong xref macro used (consider changing which macro you use). " + `Error processing path ${href}`, { current: subpath, } ); flawAttribute = ` data-flaw-src="${util.htmlEscape( flaw.macroSource )}"`; } } const titleAttribute = title ? ` title="${title}"` : ""; content ??= page.short_title ?? page.title; return `${content}`; } if (!href.toLowerCase().startsWith("/en-us/")) { // Before flagging this as a broken-link flaw, see if it's possible to // change it to the en-US URL instead. const hrefSplit = href.split("/"); hrefSplit[1] = "en-US"; const enUSPage = this.info.getPageByURL(hrefSplit.join("/")); if (enUSPage.url) { // But it's still a flaw. Record it so that translators can write a // translated document to "fill the hole". if (!ignoreFlawMacro) { flaw = this.env.recordNonFatalError( "broken-link", `${hrefpath} does not exist but fell back to ${enUSPage.url}` ); flawAttribute = ` data-flaw-src="${util.htmlEscape( flaw.macroSource )}"`; } content ??= enUSPage.short_title ?? enUSPage.title; const title = ONLY_AVAILABLE_IN_ENGLISH(this.env.locale); return ( '${content}` ); } } if (ignoreFlawMacro) { warnBrokenFlawByMacro(ignoreFlawMacro, href, "does not exist"); } else { flaw = this.env.recordNonFatalError( "broken-link", `${hrefpath} does not exist` ); flawAttribute = ` data-flaw-src="${util.htmlEscape(flaw.macroSource)}"`; } // Let's get a potentially localized title for when the document is missing. const titleWhenMissing = (this.mdn as any).getLocalString( this.web.getJSONData("L10n-Common"), "summary" ); content ??= href; return `${content}`; }, // Try calling "decodeURIComponent", but if there's an error, just // return the text unmodified. safeDecodeURIComponent: util.safeDecodeURIComponent, // Given a URL, convert all spaces to underscores. This lets us fix a // bunch of places where templates assume this is done automatically // by the API, like MindTouch did. spacesToUnderscores: util.spacesToUnderscores, // Turn the text content of a header into a slug for use in an ID. // Remove unsafe characters, and collapse whitespace gaps into // underscores. slugify: util.slugify, // Return specific .json files from the content root getJSONData(name) { const filePath = path.join(CONTENT_ROOT, "jsondata", `${name}.json`); try { return readJSONDataFile(filePath); } catch (error) { console.error(`Tried to read JSON from ${filePath}`, error); throw error; } }, }; const _readJSONDataCache = new Map(); function readJSONDataFile(filePath) { if (_readJSONDataCache.has(filePath)) { return _readJSONDataCache.get(filePath); } const payload = JSON.parse(fs.readFileSync(filePath, "utf-8")); if (process.env.NODE_ENV === "production") { _readJSONDataCache.set(filePath, payload); } return payload; } export default web;