import React, { useEffect, useRef, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { annotate, annotationGroup } from "rough-notation"; import { RoughAnnotation } from "rough-notation/lib/model"; import { diffWords } from "diff"; import { WRITER_MODE, WRITER_MODE_HOSTNAMES } from "../../env"; import { humanizeFlawName } from "../../flaw-utils"; import { useDocumentURL } from "../hooks"; import { Doc, BrokenLink, MacroErrorMessage, ImageReferenceFlaw, ImageWidthFlaw, GenericFlaw, BadBCDQueryFlaw, BadPreTagFlaw, SectioningFlaw, HeadingLinksFlaw, TranslationDifferenceFlaw, UnsafeHTMLFlaw, } from "../../../../libs/types/document"; import "./flaws.scss"; interface FlawCount { name: string; count: number; } function useAnnotations(genericFlaws: GenericFlaw[]) { useEffect(() => { const annotations: RoughAnnotation[] = []; const elements: HTMLElement[] = []; for (const flaw of genericFlaws) { const element = document.querySelector( `[data-flaw="${flaw.id}"]` ) as HTMLElement; if (!element) { console.warn(`Flaw ID '${flaw.id}' does not exist in the DOM`); continue; } elements.push(element); const annotationColor = flaw.suggestion ? "orange" : "red"; element.dataset.originalTitle = element.title; element.title = flaw.suggestion ? `Flaw suggestion: ${flaw.suggestion}` : `Flaw explanation: ${flaw.explanation}`; annotations.push( annotate(element, { type: "box", color: annotationColor, animationDuration: 300, }) ); } const ag = annotationGroup(annotations); ag.show(); return () => { ag.hide(); // Now, restore any 'title' attributes that were overridden. for (const element of elements) { if (element.dataset.originalTitle !== undefined) { element.title = element.dataset.originalTitle; } } }; }, [genericFlaws]); function focus(flawID: string) { const element = document.querySelector( `[data-flaw="${flawID}"]` ) as HTMLElement; if (!element) return; const annotations: RoughAnnotation[] = []; element.scrollIntoView({ behavior: "smooth", block: "center", }); if (element.parentElement) { annotations.push( annotate(element, { type: "circle", color: "purple", animationDuration: 500, strokeWidth: 2, padding: 6, }) ); } if (annotations.length) { const ag = annotationGroup(annotations); ag.show(); // Only show this extra highlight temporarily window.setTimeout(() => { ag.hide(); }, 2000); } } return { focus, }; } const FLAWS_HASH = "#_flaws"; export function ToggleDocumentFlaws({ doc, reloadPage, }: { doc: Doc; reloadPage: () => void; }) { const location = useLocation(); const navigate = useNavigate(); const rootElement = useRef(null); const isInitialRender = useRef(true); const show = location.hash === FLAWS_HASH; useEffect(() => { if (isInitialRender.current && show && rootElement.current) { rootElement.current.scrollIntoView({ behavior: "smooth" }); } isInitialRender.current = false; }, [show]); function toggle() { if (show) { navigate(location.pathname + location.search); } else { navigate(location.pathname + location.search + FLAWS_HASH); } } const flawsCounts = Object.entries(doc.flaws) .map(([name, actualFlaws]) => ({ name, count: actualFlaws.length, })) .sort((a, b) => b.count - a.count); React.useEffect(() => { const el = document.querySelector('link[rel="icon"]') as HTMLLinkElement; if (el) { let allFixableFlaws = 0; let allFlaws = 0; Object.values(doc.flaws).forEach((flaws) => { allFlaws += flaws.length; allFixableFlaws += flaws.filter((flaw) => flaw.fixable).length; }); el.href = !allFlaws ? "/favicon-48x48-flawless.png" : allFlaws === allFixableFlaws ? "/favicon-48x48-flaws-fixable.png" : "/favicon-48x48-flaws.png"; } }, [doc.flaws]); return (
{flawsCounts.length > 0 ? ( ) : (

No known flaws at the moment 🍾

)}{" "} {show ? ( ) : ( {/* a one-liner about all the flaws */} {flawsCounts .map((flaw) => `${humanizeFlawName(flaw.name)}: ${flaw.count}`) .join(" + ")} )}
); } function Flaws({ doc, flaws, reloadPage, }: { doc: Doc; flaws: FlawCount[]; reloadPage: () => void; }) { if (!WRITER_MODE) { throw new Error("This shouldn't be used without WRITER_MODE=true"); } const fixableFlaws = Object.values(doc.flaws) .map((flaws) => { return flaws.filter( (flaw) => !flaw.fixed && (flaw.fixable || flaw.externalImage) ); }) .flat(); const isReadOnly = !WRITER_MODE_HOSTNAMES.includes(window.location.hostname); // Note! This will work on Windows. The filename can be sent to // the server in POSIX style and the `open-editor` program will make // this work for Windows automatically. const filePath = doc.source.folder + "/" + doc.source.filename; return (
{!!fixableFlaws.length && !isReadOnly && fixableFlaws.length > 0 && ( <> {doc.isMarkdown && ( Automatic fixing fixable flaws is experimental for Markdown documents. See{" "} mdn/yari#4333 )} )}{" "} {flaws.map((flaw) => { switch (flaw.name) { case "broken_links": return ( ); case "bad_bcd_queries": return ( ); case "bad_pre_tags": return ( ); case "macros": return ( ); case "images": return ( ); case "image_widths": return ( ); case "heading_links": return ( ); case "unsafe_html": return ( ); case "translation_differences": return ( ); case "sectioning": return ( ); default: return ; } })}
); } function FixableFlawsAction({ count, reloadPage, }: { count: number; reloadPage: () => void; }) { const [fixing, setFixing] = useState(false); const [fixed, setFixed] = useState(false); const [fixingError, setFixingError] = useState(null); const documentURL = useDocumentURL(); async function fix() { try { const response = await fetch( `/_document/fixfixableflaws?${new URLSearchParams({ url: documentURL, }).toString()}`, { method: "PUT", } ); if (!response.ok) { throw new Error(`${response.status} on ${response.url}`); } setFixed(true); } catch (error: any) { console.error("Error trying to fix fixable flaws"); setFixingError(error); } finally { setFixing(false); } } return (
{fixingError && (

Error: {fixingError.toString()}

)} {" "} {fixed && Fixed!}
); } function FixableFlawBadge() { return ( Fixable{" "} 👍🏼 ); } function ShowDiff({ before, after }: { before: string; after: string }) { const diff = diffWords(before, after); const bits = diff.map((part, i: number) => { if (part.added) { return {part.value}; } else if (part.removed) { return {part.value}; } else { return {part.value}; } }); return {bits}; } function BrokenLinks({ sourceFilePath, links, isReadOnly, }: { sourceFilePath: string; links: BrokenLink[]; isReadOnly: boolean; }) { const [opening, setOpening] = React.useState(null); useEffect(() => { let unsetOpeningTimer: ReturnType; if (opening) { unsetOpeningTimer = setTimeout(() => { setOpening(null); }, 3000); } return () => { if (unsetOpeningTimer) { clearTimeout(unsetOpeningTimer); } }; }, [opening]); function openInEditor(key: string, line: number, column: number) { const sp = new URLSearchParams(); sp.set("filepath", sourceFilePath); sp.set("line", `${line}`); sp.set("column", `${column}`); console.log( `Going to try to open ${sourceFilePath}:${line}:${column} in your editor` ); setOpening(key); fetch(`/_open?${sp.toString()}`).catch((err) => { console.warn(`Error trying to _open?${sp.toString()}:`, err); }); } const { focus } = useAnnotations(links); return (

Broken Links

    {links.map((flaw, i) => { const key = `${flaw.href}${flaw.line}${flaw.column}`; return (
  1. {flaw.href}{" "} { focus(flaw.id); }} > 👀 {" "} {isReadOnly ? ( <> {/* It would be cool if we can change this to a link to the line in the file in GitHub's UI. */} line {flaw.line}:{flaw.column} ) : ( { event.preventDefault(); openInEditor(key, flaw.line, flaw.column); }} title="Click to open in your editor" > line {flaw.line}:{flaw.column} )}{" "} {flaw.fixable && }{" "} {opening && opening === key && Opening...}
    {flaw.suggestion ? ( Suggestion: ) : ( {flaw.explanation} )}
  2. ); })}
); } function Unknown({ flaws }: { flaws: GenericFlaw[] }) { return (

{humanizeFlawName("unknown")}

    {flaws.map((flaw) => (
  • {flaw.explanation}
  • ))}
); } function BadBCDQueries({ flaws }: { flaws: BadBCDQueryFlaw[] }) { return (

{humanizeFlawName("bad_bcd_queries")}

    {flaws.map((flaw) => (
  • {flaw.explanation}
  • ))}
); } function Sectioning({ flaws }: { flaws: SectioningFlaw[] }) { return (

{humanizeFlawName("sectioning")}

    {flaws.map((flaw) => (
  • {flaw.explanation}
    Usually this means there's something in the raw content that makes it hard to split up the rendered HTML. Perhaps delete unnecessary empty divs.
  • ))}
); } function BadPreTag({ flaws, sourceFilePath, isReadOnly, }: { flaws: BadPreTagFlaw[]; sourceFilePath: string; isReadOnly: boolean; }) { const { focus } = useAnnotations(flaws); const [opening, setOpening] = React.useState(null); useEffect(() => { let unsetOpeningTimer: ReturnType; if (opening) { unsetOpeningTimer = setTimeout(() => { setOpening(null); }, 3000); } return () => { if (unsetOpeningTimer) { clearTimeout(unsetOpeningTimer); } }; }, [opening]); function openInEditor(key: string, line: number, column: number) { const sp = new URLSearchParams(); sp.set("filepath", sourceFilePath); sp.set("line", `${line}`); sp.set("column", `${column}`); console.log( `Going to try to open ${sourceFilePath}:${line}:${column} in your editor` ); setOpening(key); fetch(`/_open?${sp.toString()}`).catch((err) => { console.warn(`Error trying to _open?${sp.toString()}:`, err); }); } return (

{humanizeFlawName("bad_pre_tags")}

); } function Macros({ flaws, sourceFilePath, isReadOnly, }: { flaws: MacroErrorMessage[]; sourceFilePath: string; isReadOnly: boolean; }) { const [opening, setOpening] = React.useState(null); useEffect(() => { let unsetOpeningTimer: ReturnType; if (opening) { unsetOpeningTimer = setTimeout(() => { setOpening(null); }, 3000); } return () => { if (unsetOpeningTimer) { clearTimeout(unsetOpeningTimer); } }; }, [opening]); function openInEditor(msg: MacroErrorMessage, id: string) { const sp = new URLSearchParams(); sp.set("filepath", msg.filepath); sp.set("line", `${msg.line}`); sp.set("column", `${msg.column}`); console.log( `Going to try to open ${msg.filepath}:${msg.line}:${msg.column} in your editor` ); setOpening(id); fetch(`/_open?${sp.toString()}`); } return (

{humanizeFlawName("macros")}

{flaws.map((flaw) => { const inPrerequisiteMacro = flaw.filepath ? !flaw.filepath.includes(sourceFilePath) : false; return (
{flaw.name} from {flaw.macroName}{" "} {isReadOnly ? ( <> line {flaw.line}:{flaw.column} ) : ( { event.preventDefault(); openInEditor(flaw, flaw.id); }} > line {flaw.line}:{flaw.column} )}{" "} {opening && opening === flaw.id && Opening...}{" "} {inPrerequisiteMacro && ( In prerequisite macro )}{" "} {flaw.fixable && }{" "} {flaw.fixable && flaw.suggestion && ( <> Suggestion:
)} {flaw.explanation && ( <> Explanation: {flaw.explanation}
)} Context:
{flaw.sourceContext}
Original error stack:
{flaw.errorStack}
Filepath:{" "} {inPrerequisiteMacro && ( Note that this is different from the page you're currently viewing. )}
{flaw.filepath}
); })}
); } function Images({ sourceFilePath, images, isReadOnly, }: { sourceFilePath: string; images: ImageReferenceFlaw[]; isReadOnly: boolean; }) { // XXX rewrite to a hook const [opening, setOpening] = React.useState(null); useEffect(() => { let unsetOpeningTimer: ReturnType; if (opening) { unsetOpeningTimer = setTimeout(() => { setOpening(null); }, 3000); } return () => { if (unsetOpeningTimer) { clearTimeout(unsetOpeningTimer); } }; }, [opening]); function openInEditor(key: string, line: number, column: number) { const sp = new URLSearchParams(); sp.set("filepath", sourceFilePath); sp.set("line", `${line}`); sp.set("column", `${column}`); console.log( `Going to try to open ${sourceFilePath}:${line}:${column} in your editor` ); setOpening(key); fetch(`/_open?${sp.toString()}`).catch((err) => { console.warn(`Error trying to _open?${sp.toString()}:`, err); }); } const { focus } = useAnnotations(images); return (

{humanizeFlawName("images")}

); } function ImageWidths({ sourceFilePath, flaws, isReadOnly, }: { sourceFilePath: string; flaws: ImageWidthFlaw[]; isReadOnly: boolean; }) { // XXX rewrite to a hook const [opening, setOpening] = React.useState(null); useEffect(() => { let unsetOpeningTimer: ReturnType; if (opening) { unsetOpeningTimer = setTimeout(() => { setOpening(null); }, 3000); } return () => { if (unsetOpeningTimer) { clearTimeout(unsetOpeningTimer); } }; }, [opening]); function openInEditor(key: string, line: number, column: number) { const sp = new URLSearchParams(); sp.set("filepath", sourceFilePath); sp.set("line", `${line}`); sp.set("column", `${column}`); console.log( `Going to try to open ${sourceFilePath}:${line}:${column} in your editor` ); setOpening(key); fetch(`/_open?${sp.toString()}`).catch((err) => { console.warn(`Error trying to _open?${sp.toString()}:`, err); }); } const { focus } = useAnnotations(flaws); return (

{humanizeFlawName("image_widths")}

); } function HeadingLinks({ sourceFilePath, flaws, isReadOnly, }: { sourceFilePath: string; flaws: HeadingLinksFlaw[]; isReadOnly: boolean; }) { // XXX rewrite to a hook const [opening, setOpening] = React.useState(null); useEffect(() => { let unsetOpeningTimer: ReturnType; if (opening) { unsetOpeningTimer = setTimeout(() => { setOpening(null); }, 3000); } return () => { if (unsetOpeningTimer) { clearTimeout(unsetOpeningTimer); } }; }, [opening]); function openInEditor(key: string, line: number, column: number) { const sp = new URLSearchParams(); sp.set("filepath", sourceFilePath); sp.set("line", `${line}`); sp.set("column", `${column}`); console.log( `Going to try to open ${sourceFilePath}:${line}:${column} in your editor` ); setOpening(key); fetch(`/_open?${sp.toString()}`).catch((err) => { console.warn(`Error trying to _open?${sp.toString()}:`, err); }); } return (

{humanizeFlawName("heading_links")}

); } function UnsafeHTML({ flaws }: { flaws: UnsafeHTMLFlaw[] }) { // The UI for this flaw can be a bit "simplistic" because by default this // flaw will error rather than warn. return (

⚠️ {humanizeFlawName("unsafe_html")} ⚠️

    {flaws.map((flaw, i) => { const key = flaw.id; return (
  • {flaw.explanation}{" "} {flaw.line && flaw.column && ( <> line {flaw.line}:{flaw.column} )}{" "} {flaw.fixable && }
    HTML:
    {flaw.html}

  • ); })}
); } function TranslationDifferences({ flaws, }: { flaws: TranslationDifferenceFlaw[]; }) { return (

{humanizeFlawName("translation_differences")}

    {flaws.map((flaw, i) => (
  • {{flaw.explanation}} {flaw.difference.explanationNotes && flaw.difference.explanationNotes.length > 0 && (
      {flaw.difference.explanationNotes.map( (explanationNotes, i) => { return (
    • {explanationNotes}
    • ); } )}
    )}
  • ))}
); }