From d022fab5d6207502a1b5a886d2418c1b7bc97778 Mon Sep 17 00:00:00 2001 From: Bad Manners <me@badmanners.xyz> Date: Sun, 18 Aug 2024 22:52:45 -0300 Subject: [PATCH] Improvements to a11y and scripts --- .prettierignore | 2 +- package-lock.json | 4 +- package.json | 2 +- src/components/AgeRestrictedModal.astro | 48 +++-- src/components/AutoDarkMode.astro | 4 + src/components/DarkModeScript.astro | 7 +- src/components/MastodonComments.astro | 260 ++++++++++++++--------- src/content/config.ts | 8 +- src/i18n/index.ts | 20 +- src/layouts/GalleryLayout.astro | 15 +- src/layouts/PublishedContentLayout.astro | 11 +- src/pages/api/export-story/[...slug].ts | 2 +- src/pages/games.astro | 30 ++- src/pages/index.astro | 34 ++- src/pages/stories/[page].astro | 34 ++- src/pages/tags.astro | 2 +- src/pages/tags/[slug].astro | 115 +++++++--- 17 files changed, 384 insertions(+), 214 deletions(-) create mode 100644 src/components/AutoDarkMode.astro diff --git a/.prettierignore b/.prettierignore index 768bd26..a37bf70 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,3 +1,3 @@ -src/components/DarkModeScript.astro +src/components/AutoDarkMode.astro .astro/ dist/ diff --git a/package-lock.json b/package-lock.json index 7ee01e3..ae4a1db 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gallery.badmanners.xyz", - "version": "1.7.5", + "version": "1.7.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gallery.badmanners.xyz", - "version": "1.7.5", + "version": "1.7.6", "hasInstallScript": true, "dependencies": { "@astrojs/check": "^0.9.2", diff --git a/package.json b/package.json index a9deb33..6d5ad19 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gallery.badmanners.xyz", "type": "module", - "version": "1.7.5", + "version": "1.7.6", "scripts": { "postinstall": "astro sync", "dev": "astro dev", diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro index 6d85e6b..62ac4a3 100644 --- a/src/components/AgeRestrictedModal.astro +++ b/src/components/AgeRestrictedModal.astro @@ -53,30 +53,34 @@ <script> (function () { if (localStorage.getItem("ageVerified") !== "true") { - document.body.appendChild( - document - .querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")! - .content.cloneNode(true), + const fragment = document + .querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")! + .content.cloneNode(true) as DocumentFragment; + const modal = fragment.firstElementChild as HTMLElementTagNameMap["div"]; + const controller = new AbortController(); + const { signal } = controller; + modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!.addEventListener( + "click", + (e: MouseEvent) => { + e.preventDefault(); + location.href = "about:blank"; + }, + { signal }, + ); + modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!.addEventListener( + "click", + (e: MouseEvent) => { + e.preventDefault(); + controller.abort(); + localStorage.setItem("ageVerified", "true"); + document.body.style.overflow = "auto"; + modal.remove(); + }, + { signal }, ); - const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!; - const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!; - const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!; - function onRejectButtonClick(e: MouseEvent) { - e.preventDefault(); - location.href = "about:blank"; - } - function onAcceptButtonClick(e: MouseEvent) { - e.preventDefault(); - rejectButton.removeEventListener("click", onRejectButtonClick); - acceptButton.removeEventListener("click", onAcceptButtonClick); - localStorage.setItem("ageVerified", "true"); - document.body.style.overflow = "auto"; - modal.remove(); - } - rejectButton.addEventListener("click", onRejectButtonClick); - acceptButton.addEventListener("click", onAcceptButtonClick); document.body.style.overflow = "hidden"; - rejectButton.focus(); + document.body.appendChild(fragment); + document.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")?.focus(); } })(); </script> diff --git a/src/components/AutoDarkMode.astro b/src/components/AutoDarkMode.astro new file mode 100644 index 0000000..67423e2 --- /dev/null +++ b/src/components/AutoDarkMode.astro @@ -0,0 +1,4 @@ +--- +--- + +<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script> diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro index 5dda7eb..16645fc 100644 --- a/src/components/DarkModeScript.astro +++ b/src/components/DarkModeScript.astro @@ -1,8 +1,8 @@ --- +import DarkModeInline from "./AutoDarkMode.astro"; +--- ---- - -<script is:inline>!function(){var a="dark",b="auto",c="colorScheme",d=document.body.classList,e=localStorage,f=e.getItem(c);f&&f!==b?f===a&&d.add(a):(e.setItem(c,b),matchMedia("(prefers-color-scheme: dark)").matches&&d.add(a))}();</script> +<DarkModeInline /> <script> (function () { @@ -12,6 +12,7 @@ } document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => { button.classList.remove("hidden"); + button.style.removeProperty("display"); button.setAttribute("aria-hidden", "false"); button.addEventListener("click", (e) => { e.preventDefault(); diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro index 94c7048..5467e4e 100644 --- a/src/components/MastodonComments.astro +++ b/src/components/MastodonComments.astro @@ -13,55 +13,70 @@ const { link, instance, user, postId } = Astro.props; --- <section - id="comment-section" + id="comments-section" class="px-2 font-serif" - aria-describedby="title-comment-section" + aria-describedby="title-comments-section" data-link={link} data-instance={instance} data-user={user} data-post-id={postId} > - <h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> + <h2 id="title-comments-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> Comments </h2> - <div class="text-stone-800 dark:text-stone-100" id="comments"> - <p class="my-1"> - <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>. - </p> - </div> -</section> - -<template id="template-button"> + <p id="comments-description" class="my-1 text-stone-800 dark:text-stone-100"> + <span data-noscript + ><a class="u-syndication text-link underline" href={link} target="_blank">View comments on Mastodon</a>.</span + > + <span style={{ display: "none" }} data-no-comments + >No comments yet. <a class="text-link underline" href={link} target="_blank" + >Be the first to join the conversation on Mastodon</a + >.</span + > + <span style={{ display: "none" }} data-comments + >Join the conversation <a class="text-link underline" href={link} target="_blank">by replying on Mastodon</a + >.</span + > + <span style={{ display: "none" }} data-error>Unable to load comments. Please try again later.</span> + </p> <button - class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900" + style={{ display: "none" }} + class="group mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 text-stone-800 disabled:bg-bm-400 dark:bg-green-800 dark:text-stone-100 dark:disabled:bg-green-900" id="load-comments-button" > - <span>Click to load comments</span> + <span class="block underline group-disabled:hidden">Click to load comments</span> + <span class="hidden group-disabled:block"> + <svg + style={{ width: "1.25rem", height: "1.25rem", display: "inline" }} + class="-mt-1 mr-1 animate-spin" + fill="none" + viewBox="0 0 24 24" + aria-hidden="true" + > + <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> + <path + class="opacity-100" + fill="currentColor" + d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" + ></path> + </svg> + Loading... + </span> </button> -</template> + <div id="comments" style={{ display: "none" }}></div> +</section> -<template id="template-button-loading"> - <svg - style={{ width: "1.25rem", height: "1.25rem", display: "inline" }} - class="-mt-1 mr-1 animate-spin" - fill="none" - viewBox="0 0 24 24" - aria-hidden="true" - > - <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> - <path - class="opacity-100" - fill="currentColor" - d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" - ></path> - </svg> - <span>Loading...</span> +<template id="template-comment-emoji"> + <picture> + <source media="(prefers-reduced-motion: reduce)" /> + <img width={20} class="mx-[1px] inline" /> + </picture> </template> <template id="template-comment-box"> <div role="article" - class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 dark:border-stone-600 dark:bg-stone-800" + class="p-comment h-entry my-2 rounded-md border-2 border-stone-400 bg-stone-100 p-2 text-stone-800 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100" > <div class="ml-1"> <a @@ -69,18 +84,27 @@ const { link, instance, user, postId } = Astro.props; class="p-author h-card u-url text-link flex items-center text-lg hover:underline focus:underline" target="_blank" > - <img data-avatar class="u-photo mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" /> - <span data-display-name class="p-nickname"></span> + <picture> + <source data-avatar-static media="(prefers-reduced-motion: reduce)" /> + <img data-avatar width={40} class="u-photo mr-2 rounded-full border border-stone-400 dark:border-stone-600" /> + </picture> + <span data-display-name class="p-nickname" aria-label="Display name"></span> </a> <a data-post-link class="u-url text-link my-1 flex items-center text-sm font-light hover:underline focus:underline" target="_blank" > - <time class="dt-published mr-1" data-publish-date aria-label="Publish date"></time> + <time class="dt-published mr-1" data-published-date aria-label="Publish date"></time> + <time style={{ display: "none" }} class="italic" data-edited-date>(edited)</time> </a> </div> - <div data-content class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div> + <div + data-content + class="e-content prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0" + aria-label="Comment body" + > + </div> <div class="ml-1 flex flex-row pb-2 pt-1"> <div class="flex" aria-label="Favorites"> <span data-favorites></span> @@ -99,25 +123,25 @@ const { link, instance, user, postId } = Astro.props; </svg> </div> </div> - <div data-comment-thread class="-mb-2" aria-hidden="true"></div> + <div data-comment-thread class="-mb-2" aria-hidden="true" aria-label="Replies"></div> </div> </template> <script> - interface Post { + interface MastodonPost { link: string; instance: string; user: string; postId: string; } - interface Emoji { + interface CustomEmoji { shortcode: string; url: string; static_url: string; } - interface Comment { + interface Status { id: string; in_reply_to_id: string; url: string; @@ -132,85 +156,107 @@ const { link, instance, user, postId } = Astro.props; url: string; avatar: string; avatar_static: string; - emojis: Emoji[]; + emojis: CustomEmoji[]; }; content: string; - emojis: Emoji[]; + emojis: CustomEmoji[]; } - interface ApiResponse { - ancestors: Comment[]; - descendants: Comment[]; + interface Context { + ancestors: Status[]; + descendants: Status[]; } (function () { - function replaceEmojis(text: string, emojis: Emoji[]) { - return emojis.reduce( - (acc, emoji) => - acc.replaceAll( - `:${emoji.shortcode}:`, - `<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`, - ), - text, - ); - } - - async function renderComments(section: Element, post: Post) { + async function renderComments(section: Element, post: MastodonPost) { + const commentsDescription = section.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!; + const loadCommentsButton = section.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!; const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!; try { const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`); if (!response.ok) { throw new Error(`Received error status ${response.status} - ${response.statusText}!`); } - const data: ApiResponse = await response.json(); + const data: Context = await response.json(); - const commentsList: HTMLElement[] = []; + const emojiTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( + "template#template-comment-emoji", + )!; + const emojiMap: Record<string, string> = {}; + const replaceEmojis = (text: string, emojis: CustomEmoji[]) => + emojis.reduce((acc, emoji) => { + let emojiHTML = emojiMap[emoji.url]; + if (!emojiHTML) { + const emojiPicture = emojiTemplate.content.cloneNode(true) as DocumentFragment; + const emojiStatic = emojiPicture.querySelector("source")!; + emojiStatic.srcset = emoji.static_url; + const emojiImg = emojiPicture.querySelector("img")!; + emojiImg.src = emoji.url; + emojiImg.alt = `:${emoji.shortcode}: emoji`; + emojiHTML = emojiPicture.firstElementChild!.outerHTML; + emojiMap[emoji.url] = emojiHTML; + } + return acc.replaceAll(`:${emoji.shortcode}:`, emojiHTML); + }, text); + const commentsList: DocumentFragment[] = []; const commentMap: Record<string, number> = {}; const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( "template#template-comment-box", )!; + data.descendants.forEach((comment) => { - const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement; - commentBox.id = `comment-${comment.id}`; + const commentBox = commentTemplate.content.cloneNode(true) as DocumentFragment; + commentBox.firstElementChild!.id = `comment-${comment.id}`; const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!; commentBoxAuthor.href = comment.account.url; + commentBoxAuthor.title = comment.account.acct; + commentBoxAuthor.setAttribute("aria-label", comment.account.acct); const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!; avatar.src = comment.account.avatar; - avatar.alt = `Avatar of ${comment.account.username}`; + avatar.alt = `Avatar of @${comment.account.acct}`; + const avatarStatic = + commentBoxAuthor.querySelector<HTMLElementTagNameMap["source"]>("source[data-avatar-static]")!; + avatarStatic.srcset = comment.account.avatar_static; const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!; - displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis); + displayName.insertAdjacentHTML( + "afterbegin", + replaceEmojis(comment.account.display_name, comment.account.emojis), + ); const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!; commentBoxPostLink.href = comment.url; const publishDate = - commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-publish-date]")!; - publishDate.setAttribute("datetime", comment.created_at); - publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); + commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-published-date]")!; + publishDate.dateTime = comment.created_at; + publishDate.insertAdjacentText( + "afterbegin", + new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }), + ); if (comment.edited_at) { - const edited = document.createElement("time"); - edited.className = "dt-updated italic"; - edited.setAttribute("datetime", comment.edited_at); - edited.setAttribute("title", comment.edited_at); - edited.innerText = "(edited)"; - commentBoxPostLink.appendChild(edited); + const edited = commentBoxPostLink.querySelector<HTMLElementTagNameMap["time"]>("time[data-edited-date]")!; + edited.dateTime = comment.edited_at; + edited.title = comment.edited_at; + edited.classList.remove("hidden"); + edited.classList.add("dt-updated"); + edited.style.removeProperty("display"); } const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!; - commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis); + commentBoxContent.insertAdjacentHTML("afterbegin", replaceEmojis(comment.content, comment.emojis)); const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!; - commentBoxFavorites.innerText = comment.favourites_count.toString(); + commentBoxFavorites.insertAdjacentText("afterbegin", comment.favourites_count.toString()); const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!; - commentBoxReblogs.innerText = comment.reblogs_count.toString(); + commentBoxReblogs.insertAdjacentText("afterbegin", comment.reblogs_count.toString()); if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) { commentMap[comment.id] = commentsList.length; @@ -221,24 +267,36 @@ const { link, instance, user, postId } = Astro.props; const parentThreadDiv = commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!; parentThreadDiv.setAttribute("aria-hidden", "false"); - parentThreadDiv.setAttribute("aria-label", "Replies"); parentThreadDiv.appendChild(commentBox); } }); - if (commentsList.length === 0) { - commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">Be the first to join the conversation on Mastodon</a>.</p>`; + if (commentsList.length) { + const fragment = document.createDocumentFragment(); + commentsList.forEach((comment) => fragment.appendChild(comment)); + commentsDiv.appendChild(fragment); + commentsDescription + .querySelector<HTMLElementTagNameMap["span"]>("span[data-comments]")! + .style.removeProperty("display"); + commentsDiv.style.removeProperty("display"); } else { - commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`; - commentsDiv.append(...commentsList); + commentsDescription + .querySelector<HTMLElementTagNameMap["span"]>("span[data-no-comments]")! + .style.removeProperty("display"); } } catch (e) { - commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`; console.error("Fetch Mastodon comments error", e); + commentsDescription + .querySelector<HTMLElementTagNameMap["span"]>("span[data-error]")! + .style.removeProperty("display"); + } finally { + loadCommentsButton.style.display = "none"; + loadCommentsButton.blur(); + commentsDescription.style.removeProperty("display"); } } function initCommentSection() { - const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section"); + const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comments-section"); if (!commentSection) { return; } @@ -251,21 +309,21 @@ const { link, instance, user, postId } = Astro.props; if (!post.link || !post.instance || !post.user || !post.postId) { return; } - const commentsContainer = commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!; - commentsContainer.replaceChildren( - document.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!.content.cloneNode(true), + const loadCommentsButton = + commentSection.querySelector<HTMLElementTagNameMap["button"]>("button#load-comments-button")!; + loadCommentsButton.addEventListener( + "click", + (e) => { + e.preventDefault(); + loadCommentsButton.disabled = true; + renderComments(commentSection, post as MastodonPost); + }, + { once: true }, ); - const loadCommentsButton = commentsContainer.querySelector("button")!; - loadCommentsButton.addEventListener("click", (e) => { - e.preventDefault(); - loadCommentsButton.setAttribute("disabled", "true"); - loadCommentsButton.replaceChildren( - document - .querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")! - .content.cloneNode(true), - ); - renderComments(commentSection, post as Post); - }); + const commentsDescription = commentSection.querySelector<HTMLElementTagNameMap["p"]>("p#comments-description")!; + commentsDescription.style.display = "none"; + commentsDescription.querySelector<HTMLElementTagNameMap["span"]>("span[data-noscript]")!.style.display = "none"; + loadCommentsButton.style.removeProperty("display"); } initCommentSection(); diff --git a/src/content/config.ts b/src/content/config.ts index 71c8b51..ed8b506 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -133,7 +133,7 @@ const userList = z const copyrightedCharacters = z .record(z.string(), reference("users")) .refine( - (value) => !("" in value) || Object.keys(value).length == 1, + (value) => !("" in value) || Object.keys(value).length === 1, `"copyrightedCharacters" cannot mix empty catch-all key with other keys`, ) .default({}); @@ -145,7 +145,7 @@ const publishedContent = z.object({ title: z.string(), authors: userList, contentWarning: z.string().trim(), - // Required parameters, but optional for drafts (isDraft == true) + // Required parameters, but optional for drafts (isDraft === true) pubDate: z .date() .transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)) @@ -230,7 +230,7 @@ const storiesCollection = defineCollection({ schema: ({ image }) => z .object({ - // Required parameters, but optional for drafts (isDraft == true) + // Required parameters, but optional for drafts (isDraft === true) wordCount: z.number().int().optional(), thumbnail: image().optional(), // Optional parameters @@ -260,7 +260,7 @@ const gamesCollection = defineCollection({ schema: ({ image }) => z .object({ - // Required parameters, but optional for drafts (isDraft == true) + // Required parameters, but optional for drafts (isDraft === true) platforms: z.array(platform).default([]), thumbnail: image().optional(), // Optional parameters diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 8fa7084..ccc56cc 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -7,7 +7,7 @@ const UI_STRINGS = { en: (names: string[]) => names.length <= 1 ? names.join("") - : names.length == 2 + : names.length === 2 ? `${names[0]} and ${names[1]}` : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`, tok: (names: string[]) => names.join(" en "), @@ -17,10 +17,10 @@ const UI_STRINGS = { }, "util/enumerate": { en: (count: number, nounSingular: string, nounPlural?: string) => { - if (count == 0) { + if (count === 0) { return `no ${nounPlural ?? nounSingular}`; } - if (count == 1) { + if (count === 1) { return `one ${nounSingular}`; } return `${count} ${nounPlural ?? nounSingular}`; @@ -123,14 +123,14 @@ const UI_STRINGS = { tok: "nimi lipu", }, "story/authors_aria_label": { - en: (authors: any[]) => (authors.length == 1 ? "Author" : "Authors"), + en: (authors: any[]) => (authors.length === 1 ? "Author" : "Authors"), tok: (_authors: any[]) => "jan pi pali lipu", }, "story/requesters_aria_label": { - en: (requesters: any[]) => (requesters.length == 1 ? "Requester" : "Requesters"), + en: (requesters: any[]) => (requesters.length === 1 ? "Requester" : "Requesters"), }, "story/commissioners_aria_label": { - en: (commissioners: any[]) => (commissioners.length == 1 ? "Commissioner" : "Commissioners"), + en: (commissioners: any[]) => (commissioners.length === 1 ? "Commissioner" : "Commissioners"), }, "story/warnings": { en: (wordCount: number | string | undefined, contentWarning: string) => @@ -182,7 +182,7 @@ const UI_STRINGS = { }, "game/platforms": { en: (platforms: GamePlatform[]) => { - if (platforms.length == 0) { + if (platforms.length === 0) { return ""; } const translatedPlatforms = platforms.map((platform) => { @@ -238,9 +238,9 @@ const UI_STRINGS = { }, "characters/characters_are_copyrighted_by": { en: (owner: string, charactersList: string[]) => - charactersList.length == 0 + charactersList.length === 0 ? `All characters are © ${owner}` - : charactersList.length == 1 + : charactersList.length === 1 ? `${charactersList[0]} is © ${owner}` : `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`, }, @@ -254,7 +254,7 @@ const UI_STRINGS = { if (gamesCount > 0) { content.push(UI_STRINGS["util/enumerate"].en(gamesCount, "game", "games")); } - if (content.length == 0) { + if (content.length === 0) { return `No works tagged with "${tag}".`; } return UI_STRINGS["util/capitalize"].en(`${UI_STRINGS["util/join_names"].en(content)} tagged with "${tag}".`); diff --git a/src/layouts/GalleryLayout.astro b/src/layouts/GalleryLayout.astro index 6c7a928..723d187 100644 --- a/src/layouts/GalleryLayout.astro +++ b/src/layouts/GalleryLayout.astro @@ -42,7 +42,7 @@ const currentYear = new Date().getFullYear().toString(); <div class="pt-4 text-center text-xs text-black dark:text-white"> <span >© { - currentYear == "2024" ? ( + currentYear === "2024" ? ( <time datetime="2024">2024</time> ) : ( <> @@ -64,7 +64,7 @@ const currentYear = new Date().getFullYear().toString(); d="M575.8 255.5c0 18-15 32.1-32 32.1h-32l.7 160.2c0 2.7-.2 5.4-.5 8.1V472c0 22.1-17.9 40-40 40H456c-1.1 0-2.2 0-3.3-.1c-1.4 .1-2.8 .1-4.2 .1H416 392c-22.1 0-40-17.9-40-40V448 384c0-17.7-14.3-32-32-32H256c-17.7 0-32 14.3-32 32v64 24c0 22.1-17.9 40-40 40H160 128.1c-1.5 0-3-.1-4.5-.2c-1.2 .1-2.4 .2-3.6 .2H104c-22.1 0-40-17.9-40-40V360c0-.9 0-1.9 .1-2.8V287.6H32c-18 0-32-14-32-32.1c0-9 3-17 10-24L266.4 8c7-7 15-8 22-8s15 2 21 7L564.8 231.5c8 7 12 15 11 24z" ></path> </svg> - <span id="label-main-website" class="hidden">Main website</span> + <span id="label-main-website" class="sr-only">Main website</span> </a> <a class="text-link p-1" href="/feed.xml" target="_blank" aria-labelledby="label-rss-feed"> <svg @@ -76,9 +76,14 @@ const currentYear = new Date().getFullYear().toString(); d="M64 32C28.7 32 0 60.7 0 96V416c0 35.3 28.7 64 64 64H384c35.3 0 64-28.7 64-64V96c0-35.3-28.7-64-64-64H64zM96 136c0-13.3 10.7-24 24-24c137 0 248 111 248 248c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-110.5-89.5-200-200-200c-13.3 0-24-10.7-24-24zm0 96c0-13.3 10.7-24 24-24c83.9 0 152 68.1 152 152c0 13.3-10.7 24-24 24s-24-10.7-24-24c0-57.4-46.6-104-104-104c-13.3 0-24-10.7-24-24zm0 120a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z" ></path> </svg> - <span id="label-rss-feed" class="hidden">RSS feed</span> + <span id="label-rss-feed" class="sr-only">RSS feed</span> </a> - <button data-dark-mode class="text-link hidden p-1" aria-labelledby="label-toggle-dark-mode" aria-hidden="true"> + <button + data-dark-mode + style={{ display: "none" }} + class="text-link p-1" + aria-labelledby="label-toggle-dark-mode" + > <svg style={{ width: "1.5rem", height: "1.5rem", fill: "currentColor" }} viewBox="0 0 512 512" @@ -99,7 +104,7 @@ const currentYear = new Date().getFullYear().toString(); d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" ></path> </svg> - <span id="label-toggle-dark-mode" class="hidden">{t("en", "published_content/toggle_dark_mode")}</span> + <span id="label-toggle-dark-mode" class="sr-only">{t("en", "published_content/toggle_dark_mode")}</span> </button> </div> </div> diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro index 399e93f..00e7f4e 100644 --- a/src/layouts/PublishedContentLayout.astro +++ b/src/layouts/PublishedContentLayout.astro @@ -126,7 +126,7 @@ const thumbnail = d="M48.5 224H40c-13.3 0-24-10.7-24-24V72c0-9.7 5.8-18.5 14.8-22.2s19.3-1.7 26.2 5.2L98.6 96.6c87.6-86.5 228.7-86.2 315.8 1c87.5 87.5 87.5 229.3 0 316.8s-229.3 87.5-316.8 0c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0c62.5 62.5 163.8 62.5 226.3 0s62.5-163.8 0-226.3c-62.2-62.2-162.7-62.5-225.3-1L185 183c6.9 6.9 8.9 17.2 5.2 26.2s-12.5 14.8-22.2 14.8H48.5z" ></path> </svg> - <span class="hidden" id="label-return-to" + <span class="sr-only" id="label-return-to" >{ series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title }</span @@ -146,14 +146,15 @@ const thumbnail = d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM216 336h24V272H216c-13.3 0-24-10.7-24-24s10.7-24 24-24h48c13.3 0 24 10.7 24 24v88h8c13.3 0 24 10.7 24 24s-10.7 24-24 24H216c-13.3 0-24-10.7-24-24s10.7-24 24-24zm40-208a32 32 0 1 1 0 64 32 32 0 1 1 0-64z" ></path> </svg> - <span class="hidden" id="label-go-to-description">{t(props.lang, "published_content/go_to_description")}</span + <span class="sr-only" id="label-go-to-description" + >{t(props.lang, "published_content/go_to_description")}</span > </a> <button data-dark-mode - class="text-link my-1 hidden border-l border-stone-300 p-2 dark:border-stone-700" + style={{ display: "none" }} + class="text-link my-1 border-l border-stone-300 p-2 dark:border-stone-700" aria-labelledby="label-toggle-dark-mode" - aria-hidden="true" > <svg style={{ width: "1.25rem", height: "1.25rem", fill: "currentColor" }} @@ -175,7 +176,7 @@ const thumbnail = d="M223.5 32C100 32 0 132.3 0 256S100 480 223.5 480c60.6 0 115.5-24.2 155.8-63.4c5-4.9 6.3-12.5 3.1-18.7s-10.1-9.7-17-8.5c-9.8 1.7-19.8 2.6-30.1 2.6c-96.9 0-175.5-78.8-175.5-176c0-65.8 36-123.1 89.3-153.3c6.1-3.5 9.2-10.5 7.7-17.3s-7.3-11.9-14.3-12.5c-6.3-.5-12.6-.8-19-.8z" ></path> </svg> - <span class="hidden" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span> + <span class="sr-only" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span> </button> </div> </div> diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts index 1d70b59..b2982d2 100644 --- a/src/pages/api/export-story/[...slug].ts +++ b/src/pages/api/export-story/[...slug].ts @@ -78,7 +78,7 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = commissionersList.map((commissioner) => u(commissioner)), ), ...copyrightedCharacters.map(({ user, characters }) => - t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] == "" ? [] : characters), + t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] === "" ? [] : characters), ), ].reduce(async (promise, data) => { if (!data) { diff --git a/src/pages/games.astro b/src/pages/games.astro index fce5810..525efdb 100644 --- a/src/pages/games.astro +++ b/src/pages/games.astro @@ -2,7 +2,7 @@ import { Image } from "astro:assets"; import { getCollection, getEntries, type CollectionEntry } from "astro:content"; import GalleryLayout from "../layouts/GalleryLayout.astro"; -import { DEFAULT_LANG, t } from "../i18n"; +import { t } from "../i18n"; import UserComponent from "../components/UserComponent.astro"; type GameWithPubDate = CollectionEntry<"games"> & { data: { pubDate: Date } }; @@ -24,7 +24,7 @@ const games = await Promise.all( <ul class="my-6 flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> { games.map((game, i) => ( - <li class="h-entry"> + <li class="h-entry" lang={game.data.lang}> <a class="u-url text-link hover:underline focus:underline" href={`/games/${game.slug}`} @@ -42,17 +42,31 @@ const games = await Promise.all( </div> ) : null} <div class="max-w-[288px] text-sm"> - <span class="p-name">{game.data.title}</span> + <span class="p-name" aria-label="Title"> + {game.data.title} + </span> <br /> - <time class="dt-published italic" datetime={game.data.pubDate.toISOString().slice(0, 10)}> + <time + class="dt-published italic" + datetime={game.data.pubDate.toISOString().slice(0, 10)} + aria-label="Publish date" + > {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} </time> </div> </a> - <div style={{ display: "none" }}> - {game.authors.map((author) => ( - <UserComponent rel="author" class="p-author" user={author} lang={DEFAULT_LANG} /> - ))} + <div class="sr-only"> + <p class="p-category" aria-label="Category"> + Game + </p> + <p class="p-summary" aria-label="Summary"> + {t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning)} + </p> + <div aria-label="Authors"> + {game.authors.map((author) => ( + <UserComponent rel="author" class="p-author" user={author} lang={game.data.lang} /> + ))} + </div> </div> </li> )) diff --git a/src/pages/index.astro b/src/pages/index.astro index 952d61d..a95ccde 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -3,7 +3,7 @@ import type { ImageMetadata } from "astro"; import { type CollectionEntry, type CollectionKey, getCollection, getEntries } from "astro:content"; import { Image } from "astro:assets"; import GalleryLayout from "../layouts/GalleryLayout.astro"; -import { DEFAULT_LANG, t, type Lang } from "../i18n"; +import { t, type Lang } from "../i18n"; import UserComponent from "../components/UserComponent.astro"; const MAX_ITEMS = 10; @@ -60,7 +60,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all( href: `/games/${game.slug}`, title: game.data.title, authors: await getEntries(game.data.authors), - lang: DEFAULT_LANG, + lang: game.data.lang, altText: t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning), pubDate: game.data.pubDate, }) satisfies LatestItemsEntry, @@ -99,7 +99,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all( <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> { latestItems.map((entry) => ( - <li class="h-entry break-inside-avoid"> + <li class="h-entry break-inside-avoid" lang={entry.lang}> <a class="u-url text-link hover:underline focus:underline" href={entry.href} title={entry.altText}> {entry.thumbnail ? ( <div class="flex aspect-square max-w-[192px] justify-center"> @@ -113,20 +113,34 @@ const latestItems: LatestItemsEntry[] = await Promise.all( </div> ) : null} <div class="max-w-[192px] text-sm"> - <span class="p-name">{entry.title}</span> + <span class="p-name" aria-label="Title"> + {entry.title} + </span> <br /> <span class="italic"> - <span class="p-category">{entry.type}</span> –{" "} - <time class="dt-published" datetime={entry.pubDate.toISOString().slice(0, 10)}> + <span class="p-category" aria-label="Category"> + {entry.type} + </span>{" "} + –{" "} + <time + class="dt-published" + datetime={entry.pubDate.toISOString().slice(0, 10)} + aria-label="Publish date" + > {entry.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} </time> </span> </div> </a> - <div style={{ display: "none" }}> - {entry.authors.map((author) => ( - <UserComponent rel="author" class="p-author" user={author} lang={entry.lang} /> - ))} + <div class="sr-only"> + <p class="p-summary" aria-label="Summary"> + {entry.altText} + </p> + <div aria-label="Authors"> + {entry.authors.map((author) => ( + <UserComponent rel="author" class="p-author" user={author} lang={entry.lang} /> + ))} + </div> </div> </li> )) diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro index 3176da2..5038cea 100644 --- a/src/pages/stories/[page].astro +++ b/src/pages/stories/[page].astro @@ -35,7 +35,7 @@ const totalPages = Math.ceil(page.total / page.size); <p class="my-4">The bulk of my content!</p> <p class="text-center font-light text-stone-950 dark:text-white"> { - page.start == page.end + page.start === page.end ? `Displaying story #${page.start + 1}` : `Displaying stories #${page.start + 1}–${page.end + 1}` } / {page.total} @@ -53,7 +53,7 @@ const totalPages = Math.ceil(page.total / page.size); [...Array(totalPages).keys()] .map((p) => p + 1) .map((p) => - p == page.currentPage ? ( + p === page.currentPage ? ( <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50"> {p} </span> @@ -78,7 +78,7 @@ const totalPages = Math.ceil(page.total / page.size); <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> { page.data.map((story, i) => ( - <li class="h-entry break-inside-avoid"> + <li class="h-entry break-inside-avoid" lang={story.data.lang}> <a class="u-url text-link hover:underline focus:underline" href={`/stories/${story.slug}`} @@ -96,17 +96,31 @@ const totalPages = Math.ceil(page.total / page.size); </div> ) : null} <div class="max-w-[192px] text-sm"> - <span class="p-name">{story.data.title}</span> + <span class="p-name" aria-label="Title"> + {story.data.title} + </span> <br /> - <time class="italic" datetime={story.data.pubDate.toISOString().slice(0, 10)}> + <time + class="dt-published italic" + datetime={story.data.pubDate.toISOString().slice(0, 10)} + aria-label="Publish date" + > {story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} </time> </div> </a> - <div style={{ display: "none" }}> - {story.authors.map((author) => ( - <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} /> - ))} + <div class="sr-only"> + <p class="p-category" aria-label="Category"> + Story + </p> + <p class="p-summary" aria-label="Summary"> + {t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning)} + </p> + <div aria-label="Authors"> + {story.authors.map((author) => ( + <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} /> + ))} + </div> </div> </li> )) @@ -124,7 +138,7 @@ const totalPages = Math.ceil(page.total / page.size); [...Array(totalPages).keys()] .map((p) => p + 1) .map((p) => - p == page.currentPage ? ( + p === page.currentPage ? ( <span class="border-r border-stone-400 px-4 py-1 font-semibold text-stone-900 dark:border-stone-500 dark:text-stone-50"> {p} </span> diff --git a/src/pages/tags.astro b/src/pages/tags.astro index 5373f99..9d5cf9a 100644 --- a/src/pages/tags.astro +++ b/src/pages/tags.astro @@ -31,7 +31,7 @@ uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag)); const uniqueSlugs = new Set<string>(); const categorizedTags = tagCategories .sort((a, b) => { - if (a.data.index == b.data.index) { + if (a.data.index === b.data.index) { throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`); } return a.data.index - b.data.index; diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro index b8e49ce..09b169a 100644 --- a/src/pages/tags/[slug].astro +++ b/src/pages/tags/[slug].astro @@ -1,13 +1,14 @@ --- import type { GetStaticPaths } from "astro"; import { Image } from "astro:assets"; -import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content"; +import { type CollectionEntry, type CollectionKey, getCollection, getEntries } from "astro:content"; import { Markdown } from "@astropub/md"; import { slug } from "github-slugger"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; import Prose from "../../components/Prose.astro"; import { t, DEFAULT_LANG } from "../../i18n"; import { qualifyLocalURLsInMarkdown } from "../../utils/qualify_local_urls_in_markdown"; +import UserComponent from "../../components/UserComponent.astro"; type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } }; @@ -15,8 +16,8 @@ type Props = { tag: string; description?: string; related?: string[]; - stories: EntryWithPubDate<"stories">[]; - games: EntryWithPubDate<"games">[]; + stories: (EntryWithPubDate<"stories"> & { authors: CollectionEntry<"users">[] })[]; + games: (EntryWithPubDate<"games"> & { authors: CollectionEntry<"users">[] })[]; }; type Params = { @@ -46,7 +47,7 @@ export const getStaticPaths: GetStaticPaths = async () => { (acc, category) => { category.data.tags.forEach(({ name, description, related }) => { related = related.filter((relatedTag) => { - if (relatedTag == name) { + if (relatedTag === name) { console.warn(`WARNING: Tag "${name}" should not have itself as a related tag; removing...`); return false; } @@ -66,26 +67,42 @@ export const getStaticPaths: GetStaticPaths = async () => { }, {} as Record<string, { description?: string; related?: string[] }>, ); - return [...tags] - .filter((tag) => !seriesTags.has(tag)) - .map((tag) => ({ - params: { slug: slug(tag) } satisfies Params, - props: { - tag, - description: tagDescriptions[tag]?.description, - related: tagDescriptions[tag]?.related, - stories: ( - stories.filter( - (story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag), - ) as EntryWithPubDate<"stories">[] - ).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()), - games: ( - games.filter( - (game) => !game.data.isDraft && game.data.pubDate && game.data.tags.includes(tag), - ) as EntryWithPubDate<"games">[] - ).sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()), - } satisfies Props, - })); + return await Promise.all( + [...tags] + .filter((tag) => !seriesTags.has(tag)) + .map(async (tag) => ({ + params: { slug: slug(tag) } satisfies Params, + props: { + tag, + description: tagDescriptions[tag]?.description, + related: tagDescriptions[tag]?.related, + stories: await Promise.all( + ( + stories.filter( + (story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag), + ) as EntryWithPubDate<"stories">[] + ) + .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()) + .map(async (story) => ({ + ...story, + authors: await getEntries(story.data.authors), + })), + ), + games: await Promise.all( + ( + games.filter( + (game) => !game.data.isDraft && game.data.pubDate && game.data.tags.includes(tag), + ) as EntryWithPubDate<"games">[] + ) + .sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime()) + .map(async (game) => ({ + ...game, + authors: await getEntries(game.data.authors), + })), + ), + } satisfies Props, + })), + ); }; const { props } = Astro; @@ -130,7 +147,7 @@ const totalWorksWithTag = t( </h2> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> {props.stories.map((story) => ( - <li class="h-entry break-inside-avoid"> + <li class="h-entry break-inside-avoid" lang={story.data.lang}> <a class="u-url text-link hover:underline focus:underline" href={`/stories/${story.slug}`} @@ -147,9 +164,15 @@ const totalWorksWithTag = t( </div> ) : null} <div class="max-w-[192px] text-sm"> - <span class="p-name">{story.data.title}</span> + <span class="p-name" aria-label="Title"> + {story.data.title} + </span> <br /> - <time class="dt-published italic" datetime={story.data.pubDate.toISOString().slice(0, 10)}> + <time + class="dt-published italic" + datetime={story.data.pubDate.toISOString().slice(0, 10)} + aria-label="Publish date" + > {story.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", @@ -158,6 +181,19 @@ const totalWorksWithTag = t( </time> </div> </a> + <div class="sr-only"> + <p class="p-category" aria-label="Category"> + Story + </p> + <p class="p-summary" aria-label="Summary"> + {t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning)} + </p> + <div aria-label="Authors"> + {story.authors.map((author) => ( + <UserComponent rel="author" class="p-author" user={author} lang={story.data.lang} /> + ))} + </div> + </div> </li> ))} </ul> @@ -172,7 +208,7 @@ const totalWorksWithTag = t( </h2> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> {props.games.map((game) => ( - <li class="h-entry break-inside-avoid"> + <li class="h-entry break-inside-avoid" lang={game.data.lang}> <a class="u-url text-link hover:underline focus:underline" href={`/games/${game.slug}`} @@ -189,13 +225,32 @@ const totalWorksWithTag = t( </div> ) : null} <div class="max-w-[192px] text-sm"> - <span class="p-name">{game.data.title}</span> + <span class="p-name" aria-label="Title"> + {game.data.title} + </span> <br /> - <time class="dt-published italic" datetime={game.data.pubDate.toISOString().slice(0, 10)}> + <time + class="dt-published italic" + datetime={game.data.pubDate.toISOString().slice(0, 10)} + aria-label="Publish date" + > {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} </time> </div> </a> + <div class="sr-only"> + <p class="p-category" aria-label="Category"> + Game + </p> + <p class="p-summary" aria-label="Summary"> + {t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning)} + </p> + <div aria-label="Authors"> + {game.authors.map((author) => ( + <UserComponent rel="author" class="p-author" user={author} lang={game.data.lang} /> + ))} + </div> + </div> </li> ))} </ul>