Repository: ymrl/a11y-visualizer
Pull request #134 (2024-06-11 09:32:23)

:+1: Add page and language settings
close #105


Patch:
diff --git a/src/components/SettingsEditor.tsx b/src/components/SettingsEditor.tsx
index a27f28c..8bbeb39 100644
--- a/src/components/SettingsEditor.tsx
+++ b/src/components/SettingsEditor.tsx
@@ -120,6 +120,24 @@ export const SettingsEditor = ({
               >
                 <span className="text-sm">{t("settings.sections")}</span>
               </Checkbox>
+              <Checkbox
+                onChange={(e) => {
+                  handleChangeCheckbox("page", e);
+                }}
+                checked={settings.page}
+                disabled={disabled || !settings.accessibilityInfo}
+              >
+                <span className="text-sm">{t("settings.page")}</span>
+              </Checkbox>
+              <Checkbox
+                onChange={(e) => {
+                  handleChangeCheckbox("lang", e);
+                }}
+                checked={settings.lang}
+                disabled={disabled || !settings.accessibilityInfo}
+              >
+                <span className="text-sm">{t("settings.lang")}</span>
+              </Checkbox>
             </div>
           </fieldset>
         </div>
diff --git a/src/content/Root.tsx b/src/content/Root.tsx
index e37ceb4..8f8a9ae 100644
--- a/src/content/Root.tsx
+++ b/src/content/Root.tsx
@@ -8,10 +8,16 @@ import { SettingsContext } from "./components/SettingsProvider";
 import { useLiveRegion } from "./hooks/useLiveRegion";
 import { useDebouncedCallback } from "./hooks/useDebouncedCallback";
 
+export type RootOptions = {
+  srcdoc?: boolean;
+};
+
 export const Root = ({
   parentRef,
+  options = {},
 }: {
   parentRef: React.RefObject<Element>;
+  options?: RootOptions;
 }) => {
   const [metaList, setMetaList] = React.useState<ElementMeta[]>([]);
   const [width, setWidth] = React.useState<number>(0);
@@ -19,7 +25,7 @@ export const Root = ({
   const settings = React.useContext(SettingsContext);
   const containerRef = React.useRef<HTMLDivElement>(null);
   const announcementsRef = React.useRef<HTMLDivElement>(null);
-  const framesRef = React.useRef<Window[]>([]);
+  const framesRef = React.useRef<Element[]>([]);
   const dialogsRef = React.useRef<Element[]>([]);
   const popoversRef = React.useRef<Element[]>([]);
   const { announcements, observeLiveRegion } = useLiveRegion();
@@ -27,29 +33,32 @@ export const Root = ({
   const [outdated, setOutDated] = React.useState(false);
 
   const injectToFrames = React.useCallback((el: Element) => {
-    const frames = [...el.querySelectorAll("iframe, frame")]
-      .map((f) => (f as HTMLFrameElement | HTMLIFrameElement).contentWindow)
-      .filter((f: Window | null): f is Window => f !== null);
+    const frames = [...el.querySelectorAll("iframe, frame")];
     const prevFrames = framesRef.current;
-    frames.forEach((frame) => {
-      if (!prevFrames.includes(frame)) {
-        try {
-          const d = frame.document;
-          const { readyState } = d;
-          if (readyState === "complete") {
-            injectRoot(frame, d.body);
-          } else {
-            frame.addEventListener("load", () => {
-              injectRoot(frame, d.body);
+    frames.forEach((frameEl) => {
+      const frameWindow = (frameEl as HTMLFrameElement | HTMLIFrameElement)
+        .contentWindow;
+      if (!frameWindow || prevFrames.includes(frameEl)) return;
+      try {
+        const d = frameWindow.document;
+        const { readyState } = d;
+        if (readyState === "complete") {
+          injectRoot(frameWindow, d.body, {
+            srcdoc: frameEl.hasAttribute("srcdoc"),
+          });
+        } else {
+          frameWindow.addEventListener("load", () => {
+            injectRoot(frameWindow, d.body, {
+              srcdoc: frameEl.hasAttribute("srcdoc"),
             });
-          }
-          frame.addEventListener("unload", () => {
-            setOutDated(true);
-            framesRef.current = framesRef.current.filter((f) => f !== frame);
           });
-        } catch {
-          /* noop */
         }
+        frameWindow.addEventListener("unload", () => {
+          setOutDated(true);
+          framesRef.current = framesRef.current.filter((f) => f !== frameEl);
+        });
+      } catch {
+        /* noop */
       }
     });
     framesRef.current = frames;
@@ -87,6 +96,7 @@ export const Root = ({
             ...dialogsRef.current,
           ].filter((el): el is Element => !!el),
           settings,
+          { srcdoc: options.srcdoc },
         );
 
         setMetaList(elements);
diff --git a/src/content/components/ElementInfo.css b/src/content/components/ElementInfo.css
index 494f5e3..17f9665 100644
--- a/src/content/components/ElementInfo.css
+++ b/src/content/components/ElementInfo.css
@@ -48,15 +48,20 @@
   flex-wrap: wrap;
 }
 
-.ElementInfo__tips--left-aligned {
+.ElementInfo__tips--left {
   left: 0;
   justify-content: flex-start;
 }
 
-.ElementInfo__tips--right-aligned {
+.ElementInfo__tips--right {
   right: 0;
   justify-content: flex-end;
 }
+.ElementInfo__tips--center {
+  left: 50%;
+  transform: translateX(-50%);
+  justify-content: center;
+}
 
 .ElementInfo__tips--inner-top {
   top: 0;
diff --git a/src/content/components/ElementInfo.tsx b/src/content/components/ElementInfo.tsx
index e209259..4a9577b 100644
--- a/src/content/components/ElementInfo.tsx
+++ b/src/content/components/ElementInfo.tsx
@@ -37,22 +37,28 @@ export const ElementInfo = ({
     };
   }, []);
 
-  const rightAligned: boolean =
-    ((category === "section" || category === "fieldset") &&
-      width > tipFontSize * 16) ||
-    (width < tipFontSize * 16 && x + width > rootWidth - tipFontSize * 16);
+  const horizontalPosition =
+    category === "page"
+      ? "center"
+      : ((category === "section" || category === "fieldset") &&
+            width > tipFontSize * 16) ||
+          (width < tipFontSize * 16 && x + width > rootWidth - tipFontSize * 16)
+        ? "right"
+        : "left";
   const verticalPosition: VerticalPosition =
-    category === "section" || category === "heading"
-      ? y < tipFontSize * 2.4
-        ? "inner-top"
-        : "outer-top"
-      : category === "image" || category === "fieldset"
-        ? y > tipFontSize * 2.4 && height < tipFontSize * 3.2
-          ? "outer-top"
-          : "inner-top"
-        : y + height > rootHeight - tipFontSize * 2.4
-          ? "inner-bottom"
-          : "outer-bottom";
+    category === "page"
+      ? "inner-top"
+      : category === "section" || category === "heading"
+        ? y < tipFontSize * 2.4
+          ? "inner-top"
+          : "outer-top"
+        : category === "image" || category === "fieldset"
+          ? y > tipFontSize * 2.4 && height < tipFontSize * 3.2
+            ? "outer-top"
+            : "inner-top"
+          : y + height > rootHeight - tipFontSize * 2.4
+            ? "inner-bottom"
+            : "outer-bottom";
 
   const disappear = () => {
     setHovered(false);
@@ -91,7 +97,10 @@ export const ElementInfo = ({
     appear();
   };
   const tipMaxWidth =
-    (rightAligned ? x + width : rootWidth - x) - TIP_SIDE_MARGIN;
+    horizontalPosition === "center"
+      ? width - 2 * TIP_SIDE_MARGIN
+      : (horizontalPosition === "right" ? x + width : rootWidth - x) -
+        TIP_SIDE_MARGIN;
   return (
     <div
       className={`ElementInfo${hovered ? " ElementInfo--hovered" : ""}`}
@@ -139,9 +148,7 @@ export const ElementInfo = ({
           className={[
             "ElementInfo__tips",
             `ElementInfo__tips--${verticalPosition}`,
-            rightAligned
-              ? "ElementInfo__tips--right-aligned"
-              : "ElementInfo__tips--left-aligned",
+            `ElementInfo__tips--${horizontalPosition}`,
           ].join(" ")}
           style={{
             fontSize: tipFontSize,
diff --git a/src/content/components/Tip.css b/src/content/components/Tip.css
index bcab2a5..3461ad1 100644
--- a/src/content/components/Tip.css
+++ b/src/content/components/Tip.css
@@ -12,6 +12,10 @@
   background-color: var(--base-green);
 }
 
+.Tip--pageTitle {
+  background-color: var(--base-green);
+}
+
 .Tip--role {
   background-color: var(--base-pink);
 }
@@ -43,6 +47,9 @@
 .Tip--ariaStatus {
   background-color: var(--base-beige);
 }
+.Tip--lang {
+  background-color: var(--base-cream);
+}
 
 .Tip__icon {
   width: 1.2em;
diff --git a/src/content/components/Tip.tsx b/src/content/components/Tip.tsx
index a755b8b..6b8afa6 100644
--- a/src/content/components/Tip.tsx
+++ b/src/content/components/Tip.tsx
@@ -4,10 +4,12 @@ import {
   IoAccessibility,
   IoAlertCircle,
   IoBookmark,
+  IoBrowsersOutline,
   IoCodeSlash,
   IoDocument,
   IoFlag,
   IoInformationCircle,
+  IoLanguage,
   IoPricetag,
   IoWarning,
 } from "react-icons/io5";
@@ -87,6 +89,23 @@ const Icon = ({ type }: { type: TipType }) => {
           aria-label={t("tip.ariaStatus")}
         />
       );
+
+    case "pageTitle":
+      return (
+        <IoBrowsersOutline
+          className="Tip__icon"
+          role="img"
+          aria-label={t("tip.pageTitle")}
+        />
+      );
+    case "lang":
+      return (
+        <IoLanguage
+          className="Tip__icon"
+          role="img"
+          aria-label={t("tip.lang")}
+        />
+      );
   }
 };
 export const Tip = ({
diff --git a/src/content/dom/collectElements.ts b/src/content/dom/collectElements.ts
index 4f396df..8512cec 100644
--- a/src/content/dom/collectElements.ts
+++ b/src/content/dom/collectElements.ts
@@ -17,6 +17,8 @@ import { imageTips, isImage, ImageSelectors } from "./tips/imageTips";
 import { computeAccessibleName } from "dom-accessibility-api";
 import { CategorySettings } from "../../settings";
 import { SectionSelectors, isSection, sectionTips } from "./tips/sectionTips";
+import { isPage, pageTips } from "./tips/pageTips";
+import { LangSelectors, langTips, isLang } from "./tips/langTips";
 
 const getSelector = (settings: Partial<CategorySettings>) => {
   return [
@@ -27,6 +29,7 @@ const getSelector = (settings: Partial<CategorySettings>) => {
     ...(settings.button ? ButtonSelectors : []),
     ...(settings.ariaHidden ? AriraHiddenSelectors : []),
     ...(settings.section ? SectionSelectors : []),
+    ...(settings.lang ? LangSelectors : []),
   ].join(",");
 };
 
@@ -41,6 +44,9 @@ export const collectElements = (
     heading: true,
     ariaHidden: true,
   },
+  options: {
+    srcdoc?: boolean;
+  } = {},
 ): {
   elements: ElementMeta[];
   rootWidth: number;
@@ -83,7 +89,10 @@ export const collectElements = (
     rootHeight,
     rootWidth,
     elements: selector
-      ? [...root.querySelectorAll(getSelector(settings))]
+      ? [
+          ...(settings.page && rootTagName === "body" ? [root] : []),
+          ...root.querySelectorAll(getSelector(settings)),
+        ]
           .filter((el) => !isHidden(el))
           .map((el: Element) => {
             if (excludes.some((exclude: Element) => exclude.contains(el)))
@@ -102,28 +111,21 @@ export const collectElements = (
             const nameTips: ElementTip[] = name
               ? [{ type: "name", content: name }]
               : [];
-            const images = imageTips(el, name);
-            const forms = formTips(el, name);
-            const buttons = buttonTips(el, name);
-            const links = linkTips(el, name);
-            const heading = headingTips(el, name);
-            const ariaHidden = ariaHiddenTips(el);
-            const section = sectionTips(el, name);
-            const global = globalTips(el);
-
             return {
               ...elementPosition,
               category: getElementCategory(el),
               tips: [
-                ...heading,
+                ...headingTips(el, name),
                 ...nameTips,
-                ...images,
-                ...forms,
-                ...buttons,
-                ...links,
-                ...ariaHidden,
-                ...section,
-                ...global,
+                ...imageTips(el, name),
+                ...formTips(el, name),
+                ...buttonTips(el, name),
+                ...linkTips(el, name),
+                ...ariaHiddenTips(el),
+                ...sectionTips(el, name),
+                ...langTips(el),
+                ...pageTips(el, !!options.srcdoc),
+                ...globalTips(el),
               ],
             };
           })
@@ -133,10 +135,11 @@ export const collectElements = (
 };
 
 const getElementCategory = (el: Element): Category => {
+  if (isPage(el)) return "page";
   if (isImage(el)) return "image";
   if (isHeading(el)) return "heading";
   if (isFormControl(el) || isLink(el) || isButton(el)) return "control";
-  if (isSection(el)) return "section";
   if (isFieldset(el)) return "fieldset";
+  if (isSection(el) || isLang(el)) return "section";
   return "general";
 };
diff --git a/src/content/dom/tips/langTips.ts b/src/content/dom/tips/langTips.ts
new file mode 100644
index 0000000..cd7209e
--- /dev/null
+++ b/src/content/dom/tips/langTips.ts
@@ -0,0 +1,17 @@
+import { ElementTip } from "../../types";
+export const LangSelectors = ["[lang]", "[xml\\:lang]"] as const;
+
+export const isLang = (el: Element): boolean => {
+  return el.hasAttribute("lang") || el.hasAttribute("xml:lang");
+};
+export const langTips = (el: Element): ElementTip[] => {
+  const lang = el.getAttribute("lang");
+  const xmlLang = el.getAttribute("xml:lang");
+
+  if (lang && xmlLang && lang !== xmlLang) {
+    return [{ type: "error", content: "messages.langAndXmlLangMustBeSame" }];
+  } else if (lang || xmlLang) {
+    return [{ type: "lang", content: lang || xmlLang || "" }];
+  }
+  return [];
+};
diff --git a/src/content/dom/tips/pageTips.ts b/src/content/dom/tips/pageTips.ts
new file mode 100644
index 0000000..4741687
--- /dev/null
+++ b/src/content/dom/tips/pageTips.ts
@@ -0,0 +1,24 @@
+import { ElementTip } from "../../types";
+import { langTips } from "./langTips";
+
+export const isPage = (el: Element): boolean => {
+  return el.tagName.toLowerCase() === "body";
+};
+
+export const pageTips = (el: Element, srcdoc: boolean): ElementTip[] => {
+  if (!isPage(el)) return [];
+  const result: ElementTip[] = [];
+  const d = el.ownerDocument;
+  const h = d.documentElement;
+  const lang = langTips(h);
+  if (!srcdoc && !d.title) {
+    result.push({ type: "error", content: "messages.noTitlePage" });
+  }
+  if (d.title) {
+    result.push({ type: "pageTitle", content: d.title });
+  }
+  if (lang.length === 0) {
+    result.push({ type: "error", content: "messages.noLangPage" });
+  }
+  return [...result, ...lang];
+};
diff --git a/src/content/injectRoot.tsx b/src/content/injectRoot.tsx
index 400c494..ee39193 100644
--- a/src/content/injectRoot.tsx
+++ b/src/content/injectRoot.tsx
@@ -1,11 +1,11 @@
 import React from "react";
 import ReactDOM from "react-dom/client";
-import { Root } from "./Root";
+import { Root, RootOptions } from "./Root";
 import { Settings, SettingsMessage, loadHostSettings } from "../settings";
 import { SettingsProvider } from "./components/SettingsProvider";
 import { loadEnabled } from "../enabled";
 
-const mount = (w: Window, parent: Element) => {
+const mount = (w: Window, parent: Element, options?: RootOptions) => {
   const rootElement = w.document.createElement("div");
   parent.append(rootElement);
   const root = ReactDOM.createRoot(rootElement);
@@ -14,7 +14,7 @@ const mount = (w: Window, parent: Element) => {
     root.render(
       <React.StrictMode>
         <SettingsProvider settings={settings}>
-          <Root parentRef={parentRef} />
+          <Root parentRef={parentRef} options={options} />
         </SettingsProvider>
       </React.StrictMode>,
     );
@@ -29,14 +29,18 @@ const mount = (w: Window, parent: Element) => {
   };
 };
 
-export const injectRoot = async (w: Window, parent: Element) => {
+export const injectRoot = async (
+  w: Window,
+  parent: Element,
+  options?: RootOptions,
+) => {
   if (!w.location.href.startsWith("http")) {
     return;
   }
   let [settings] = await loadHostSettings(w.location.host);
   const enabled = await loadEnabled();
 
-  let mountReturn = enabled ? mount(w, parent) : null;
+  let mountReturn = enabled ? mount(w, parent, options) : null;
   mountReturn?.render(settings);
 
   const listener = (message: SettingsMessage) => {
diff --git a/src/content/types.ts b/src/content/types.ts
index 8e7e1c0..d85c43b 100644
--- a/src/content/types.ts
+++ b/src/content/types.ts
@@ -20,6 +20,8 @@ export type TipType =
   | "description"
   | "level"
   | "ariaStatus"
+  | "lang"
+  | "pageTitle"
   | "warning"
   | "error";
 export type ElementTip = {
@@ -33,4 +35,5 @@ export type Category =
   | "control"
   | "section"
   | "fieldset"
+  | "page"
   | "general";
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 4b3a7fc..8645c14 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -20,6 +20,8 @@
     "headings": "Headings",
     "ariaHidden": "aria-hidden",
     "sections": "Sections",
+    "lang": "Langage",
+    "page": "Page",
     "tipOpacityPercent": "Tips opacity (%)",
     "interactiveMode": "Interactive mode",
     "hideLabels": "Hide labels",
@@ -39,6 +41,8 @@
     "description": "Description",
     "heading": "Heading",
     "ariaStatus": "WAI-ARIA Status",
+    "pageTitle": "Page title",
+    "lang": "Language",
     "warning": "Warning",
     "error": "Error"
   },
@@ -98,7 +102,10 @@
     "noNameAttr": "No name attribute",
     "noRadioGroup": "No radiobutton group",
     "noNameSection": "No name <section>",
-    "invalidAriaValue": "Invalid WAI-ARIA Attribute Value"
+    "invalidAriaValue": "Invalid WAI-ARIA Attribute Value",
+    "noTitlePage": "No <title> element",
+    "noLangPage": "No lang attribute on <html>",
+    "langAndXmlLangMustBeSame": "lang and xml:lang attributes must have the same value"
   },
   "optionsPage": {
     "description": "Settings changed from the popup are saved per host of the web page, and will be applied to other pages on the same host. Changing settings here does not affect those values.",
diff --git a/src/i18n/ja.json b/src/i18n/ja.json
index ef96858..3ccedda 100644
--- a/src/i18n/ja.json
+++ b/src/i18n/ja.json
@@ -20,6 +20,8 @@
     "headings": "見出し",
     "ariaHidden": "aria-hidden",
     "sections": "セクション",
+    "lang": "言語",
+    "page": "ページ",
     "tipOpacityPercent": "チップの不透明度（%）",
     "interactiveMode": "インタラクティブモード",
     "hideLabels": "ラベルを隠す",
@@ -39,6 +41,8 @@
     "description": "説明",
     "heading": "見出し",
     "ariaStatus": "WAI-ARIAステータス",
+    "pageTitle": "ページタイトル",
+    "lang": "言語",
     "warning": "警告",
     "error": "エラー"
   },
@@ -104,7 +108,10 @@
     "noNameAttr": "name属性なし",
     "noRadioGroup": "ラジオボタングループなし",
     "noNameSection": "名前なしの <section>",
-    "invalidAriaValue": "WAI-ARIA属性値が不正"
+    "invalidAriaValue": "WAI-ARIA属性値が不正",
+    "noTitlePage": "<title>要素なし",
+    "noLangPage": "<html>要素にlang属性なし",
+    "langAndXmlLangMustBeSame": "lang属性とxml:lang属性の値が異なる"
   },
   "optionsPage": {
     "description": "拡張機能のポップアップから設定を変更すると、閲覧中のWebページのホストごとに設定が保存され、同じホストのWebページに対して適用されます。この画面で設定を変更しても、それらの設定値は変更されません。",
diff --git a/src/settings/constatns.ts b/src/settings/constatns.ts
index d0db757..072dfce 100644
--- a/src/settings/constatns.ts
+++ b/src/settings/constatns.ts
@@ -12,6 +12,8 @@ export const initialSettings: Settings = {
   heading: true,
   ariaHidden: true,
   section: true,
+  lang: true,
+  page: true,
   interactiveMode: true,
   hideTips: true,
   showLiveRegions: true,
diff --git a/src/settings/types.ts b/src/settings/types.ts
index 454465a..93c4c83 100644
--- a/src/settings/types.ts
+++ b/src/settings/types.ts
@@ -6,6 +6,8 @@ export type CategorySettings = {
   heading: boolean;
   ariaHidden: boolean;
   section: boolean;
+  lang: boolean;
+  page: boolean;
 };
 
 export type Settings = {


Test patch:
diff --git a/src/content/dom/tips/langTips.test.ts b/src/content/dom/tips/langTips.test.ts
new file mode 100644
index 0000000..3af2bd2
--- /dev/null
+++ b/src/content/dom/tips/langTips.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, test } from "vitest";
+import { langTips } from "./langTips";
+
+describe("langTips", () => {
+  test("div", () => {
+    document.createElement("div");
+    expect(langTips(document.body)).toEqual([]);
+  });
+
+  test("div with lang attribute", () => {
+    const div = document.createElement("div");
+    div.setAttribute("lang", "en");
+    expect(langTips(div)).toEqual([{ type: "lang", content: "en" }]);
+  });
+
+  test("div with xml:lang attribute", () => {
+    const div = document.createElement("div");
+    div.setAttribute("xml:lang", "en");
+    expect(langTips(div)).toEqual([{ type: "lang", content: "en" }]);
+  });
+
+  test("div with xml:lang and lang attribute", () => {
+    const div = document.createElement("div");
+    div.setAttribute("xml:lang", "en");
+    div.setAttribute("lang", "en");
+    expect(langTips(div)).toEqual([{ type: "lang", content: "en" }]);
+  });
+
+  test("lang and xml:lang attribute are not same", () => {
+    const div = document.createElement("div");
+    div.setAttribute("xml:lang", "en");
+    div.setAttribute("lang", "ja");
+    expect(langTips(div)).toEqual([
+      { type: "error", content: "messages.langAndXmlLangMustBeSame" },
+    ]);
+  });
+});
diff --git a/src/content/dom/tips/pageTips.test.ts b/src/content/dom/tips/pageTips.test.ts
new file mode 100644
index 0000000..4210f76
--- /dev/null
+++ b/src/content/dom/tips/pageTips.test.ts
@@ -0,0 +1,103 @@
+import { afterEach, describe, expect, test } from "vitest";
+import { isPage, pageTips } from "./pageTips";
+
+describe("isPage", () => {
+  test("div", () => {
+    const div = document.createElement("div");
+    expect(isPage(div)).toBe(false);
+  });
+
+  test("body", () => {
+    expect(isPage(document.body)).toBe(true);
+  });
+});
+
+describe("pageTips", () => {
+  afterEach(() => {
+    document.body.innerHTML = "";
+    document.head.innerHTML = "";
+    document.documentElement.removeAttribute("lang");
+    document.documentElement.removeAttribute("xml:lang");
+  });
+
+  test("div", () => {
+    const div = document.createElement("div");
+    expect(pageTips(div, false)).toEqual([]);
+  });
+
+  test("body", () => {
+    const result = pageTips(document.body, false);
+    expect(result).toHaveLength(2);
+    expect(
+      result.find(
+        (tip) => tip.type === "error" && tip.content === "messages.noTitlePage",
+      ),
+    ).toBeDefined;
+    expect(
+      result.find(
+        (tip) => tip.type === "error" && tip.content === "messages.noLangPage",
+      ),
+    ).toBeDefined;
+  });
+
+  test("body with lang", () => {
+    document.documentElement.setAttribute("lang", "en");
+    const result = pageTips(document.body, false);
+    expect(result).toHaveLength(2);
+    expect(
+      result.find(
+        (tip) => tip.type === "error" && tip.content === "messages.noTitlePage",
+      ),
+    ).toBeDefined;
+    expect(result.find((tip) => tip.type === "lang" && tip.content === "en"))
+      .toBeDefined;
+  });
+
+  test("body with title", () => {
+    document.title = "title";
+    const result = pageTips(document.body, false);
+    expect(result).toHaveLength(2);
+    expect(
+      result.find(
+        (tip) => tip.type === "lang" && tip.content === "messages.noLangPage",
+      ),
+    ).toBeDefined;
+    expect(
+      result.find((tip) => tip.type === "pageTitle" && tip.content === "title"),
+    ).toBeDefined;
+  });
+
+  test("body with srcdoc", () => {
+    const result = pageTips(document.body, true);
+    expect(result).toHaveLength(1);
+    expect(
+      result.find(
+        (tip) => tip.type === "error" && tip.content === "messages.noLangPage",
+      ),
+    ).toBeDefined;
+  });
+
+  test("body with lang and title", () => {
+    document.title = "title";
+    document.documentElement.setAttribute("lang", "en");
+    const result = pageTips(document.body, false);
+    expect(result).toHaveLength(2);
+    expect(result.find((tip) => tip.type === "lang" && tip.content === "en"))
+      .toBeDefined;
+    expect(
+      result.find((tip) => tip.type === "pageTitle" && tip.content === "title"),
+    );
+  });
+
+  test("document has xml:lang", () => {
+    document.title = "title";
+    document.documentElement.setAttribute("xml:lang", "en");
+    const result = pageTips(document.body, false);
+    expect(result).toHaveLength(2);
+    expect(result.find((tip) => tip.type === "lang" && tip.content === "en"))
+      .toBeDefined;
+    expect(
+      result.find((tip) => tip.type === "pageTitle" && tip.content === "title"),
+    );
+  });
+});