Update licenses and improve sr-only elements
This commit is contained in:
parent
287f2cae2f
commit
90fc60e871
11 changed files with 126 additions and 82 deletions
|
@ -14,7 +14,10 @@ export default defineConfig({
|
||||||
markdownIntegration(),
|
markdownIntegration(),
|
||||||
htaccessIntegration({
|
htaccessIntegration({
|
||||||
generateHtaccessFile: import.meta.env.APACHE_CONFIG === "true",
|
generateHtaccessFile: import.meta.env.APACHE_CONFIG === "true",
|
||||||
redirects: [ { match: "/story/", url: "/stories/" }, { match: "/game/", url: "/games/" } ],
|
redirects: [
|
||||||
|
{ match: "/story/", url: "/stories/" },
|
||||||
|
{ match: "/game/", url: "/games/" },
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
pagefindIntegration(),
|
pagefindIntegration(),
|
||||||
],
|
],
|
||||||
|
|
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
||||||
{
|
{
|
||||||
"name": "gallery.badmanners.xyz",
|
"name": "gallery.badmanners.xyz",
|
||||||
"version": "1.7.11",
|
"version": "1.7.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "gallery.badmanners.xyz",
|
"name": "gallery.badmanners.xyz",
|
||||||
"version": "1.7.11",
|
"version": "1.7.12",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.2",
|
"@astrojs/check": "^0.9.2",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "gallery.badmanners.xyz",
|
"name": "gallery.badmanners.xyz",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "1.7.11",
|
"version": "1.7.12",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"postinstall": "astro sync",
|
"postinstall": "astro sync",
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
|
|
|
@ -5,7 +5,7 @@ title = "gallery.badmanners.xyz"
|
||||||
description = "Bad Manners's self-hosted gallery."
|
description = "Bad Manners's self-hosted gallery."
|
||||||
type = "website"
|
type = "website"
|
||||||
date = "2024"
|
date = "2024"
|
||||||
author = "Bad Manners <me@badmanners.xyz>"
|
author = { name = "Bad Manners", url = "https://badmanners.xyz", email = "me@badmanners.xyz>" }
|
||||||
source = "https://git.badmanners.xyz/badmanners/gallery.badmanners.xyz"
|
source = "https://git.badmanners.xyz/badmanners/gallery.badmanners.xyz"
|
||||||
license = { name = "MIT", url = "https://opensource.org/license/mit" }
|
license = { name = "MIT", url = "https://opensource.org/license/mit" }
|
||||||
notes = """
|
notes = """
|
||||||
|
@ -13,21 +13,26 @@ All rights reserved.
|
||||||
The MIT License applies only to the source code; see additional copyrights for details."""
|
The MIT License applies only to the source code; see additional copyrights for details."""
|
||||||
|
|
||||||
[[copyright.additional]]
|
[[copyright.additional]]
|
||||||
|
type = "logo"
|
||||||
notes = "The briefcase logo is copyrighted and trademarked by me. All rights reserved."
|
notes = "The briefcase logo is copyrighted and trademarked by me. All rights reserved."
|
||||||
|
|
||||||
[[copyright.additional]]
|
[[copyright.additional]]
|
||||||
|
type = "characters"
|
||||||
notes = """
|
notes = """
|
||||||
My characters, whether directly attributed to me or unattributed, are copyrighted and trademarked by me.
|
My characters, whether directly attributed to me or unattributed, are copyrighted and trademarked by me.
|
||||||
All rights reserved."""
|
All rights reserved."""
|
||||||
|
|
||||||
[[copyright.additional]]
|
[[copyright.additional]]
|
||||||
|
type = "content"
|
||||||
description = "Content hosted on this website, i.e. the stories and game(s)."
|
description = "Content hosted on this website, i.e. the stories and game(s)."
|
||||||
date = "2022-2024"
|
date = "2022-2024"
|
||||||
author = "Bad Manners <me@badmanners.xyz>"
|
author = { name = "Bad Manners", url = "https://badmanners.xyz", email = "me@badmanners.xyz>" }
|
||||||
|
source = "https://git.badmanners.xyz/badmanners/gallery.badmanners.xyz/src/branch/main/src/content"
|
||||||
license = { name = "CC-BY-NC-ND-4.0", url = "https://creativecommons.org/licenses/by-nc-nd/4.0/" }
|
license = { name = "CC-BY-NC-ND-4.0", url = "https://creativecommons.org/licenses/by-nc-nd/4.0/" }
|
||||||
notes = "All rights reserved."
|
notes = "All rights reserved."
|
||||||
|
|
||||||
[[copyright.additional]]
|
[[copyright.additional]]
|
||||||
|
type = "third-party trademarks"
|
||||||
notes = """
|
notes = """
|
||||||
All third-party copyrights, trademarks, and attributed characters belong to their respective owners, \
|
All third-party copyrights, trademarks, and attributed characters belong to their respective owners, \
|
||||||
and I'm not affiliated with any of them."""
|
and I'm not affiliated with any of them."""
|
||||||
|
|
|
@ -49,8 +49,8 @@ const isCurrentRoute = (path: string) =>
|
||||||
class="u-logo my-4 w-full max-w-[192px] rounded-sm border-2 border-green-950 shadow-md"
|
class="u-logo my-4 w-full max-w-[192px] rounded-sm border-2 border-green-950 shadow-md"
|
||||||
width={192}
|
width={192}
|
||||||
/>
|
/>
|
||||||
<span class="p-name mt-2 mb-4 text-2xl font-semibold">Bad Manners</span>
|
<span class="p-name mb-4 mt-2 text-2xl font-semibold">Bad Manners</span>
|
||||||
<ul class="flex flex-col gap-y-2 text-left pr-8 sm:pr-2">
|
<ul class="flex flex-col gap-y-2 pr-8 text-left sm:pr-2">
|
||||||
<li>
|
<li>
|
||||||
<a class="u-url text-link group" href="https://badmanners.xyz/" data-age-restricted rel="me">
|
<a class="u-url text-link group" href="https://badmanners.xyz/" data-age-restricted rel="me">
|
||||||
<IconHome width="1.25rem" height="1.25rem" class="inline align-text-top" />
|
<IconHome width="1.25rem" height="1.25rem" class="inline align-text-top" />
|
||||||
|
|
|
@ -128,7 +128,7 @@ const thumbnail =
|
||||||
aria-labelledby="label-return-to"
|
aria-labelledby="label-return-to"
|
||||||
>
|
>
|
||||||
<IconArrowBack width="1.25rem" height="1.25rem" />
|
<IconArrowBack width="1.25rem" height="1.25rem" />
|
||||||
<span class="sr-only" id="label-return-to"
|
<span class="sr-only select-none" id="label-return-to"
|
||||||
>{
|
>{
|
||||||
series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title
|
series ? t(props.lang, "published_content/return_to_series", series.data.name) : props.labelReturnTo.title
|
||||||
}</span
|
}</span
|
||||||
|
@ -140,7 +140,7 @@ const thumbnail =
|
||||||
aria-labelledby="label-go-to-description"
|
aria-labelledby="label-go-to-description"
|
||||||
>
|
>
|
||||||
<IconCircleInfo width="1.25rem" height="1.25rem" />
|
<IconCircleInfo width="1.25rem" height="1.25rem" />
|
||||||
<span class="sr-only" id="label-go-to-description"
|
<span class="sr-only select-none" id="label-go-to-description"
|
||||||
>{t(props.lang, "published_content/go_to_description")}</span
|
>{t(props.lang, "published_content/go_to_description")}</span
|
||||||
>
|
>
|
||||||
</a>
|
</a>
|
||||||
|
@ -152,7 +152,7 @@ const thumbnail =
|
||||||
>
|
>
|
||||||
<IconSun width="1.25rem" height="1.25rem" class="hidden dark:block" />
|
<IconSun width="1.25rem" height="1.25rem" class="hidden dark:block" />
|
||||||
<IconMoon width="1.25rem" height="1.25rem" class="block dark:hidden" />
|
<IconMoon width="1.25rem" height="1.25rem" class="block dark:hidden" />
|
||||||
<span class="sr-only" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
|
<span class="sr-only select-none" id="label-toggle-dark-mode">{t(props.lang, "published_content/toggle_dark_mode")}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -55,7 +55,7 @@ const games = await Promise.all(
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sr-only">
|
<div class="sr-only select-none">
|
||||||
<p class="p-category" aria-label="Category">
|
<p class="p-category" aria-label="Category">
|
||||||
Game
|
Game
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -133,7 +133,7 @@ const latestItems: LatestItemsEntry[] = await Promise.all(
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sr-only">
|
<div class="sr-only select-none">
|
||||||
<p class="p-summary" aria-label="Summary">
|
<p class="p-summary" aria-label="Summary">
|
||||||
{entry.altText}
|
{entry.altText}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -3,71 +3,88 @@ import { readFile } from "node:fs/promises";
|
||||||
import { parse } from "toml";
|
import { parse } from "toml";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify attributions and copyright according to the [Creative Commons recommended practices](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution)
|
* Makes sure the copyright follows the TASL format. T = title (or description), A = author, S = source, L = license.
|
||||||
|
* @param copyright
|
||||||
|
*/
|
||||||
|
function validateTASL(copyright: any) {
|
||||||
|
const title = copyright.title ?? copyright.description;
|
||||||
|
if (typeof title !== "string" || !title) {
|
||||||
|
throw new Error(`Missing "title" and/or "description" for attribution (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
// Author must be a valid string or object or list
|
||||||
|
const authors = [copyright.author].flat();
|
||||||
|
if (authors.length === 0) {
|
||||||
|
throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
authors.forEach((author) => {
|
||||||
|
if (!author) {
|
||||||
|
throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
if (typeof author !== "object" && typeof author !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid "${typeof author}" type for "author"${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof author === "object" && !(author.name || author.url)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing both name and URL for "author" ${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Source must be a valid string or list of strings
|
||||||
|
const sources = [copyright.source].flat();
|
||||||
|
if (sources.length === 0) {
|
||||||
|
throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
sources.forEach((source) => {
|
||||||
|
if (!source) {
|
||||||
|
throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
if (typeof source !== "object" && typeof source !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid "${typeof source}" type for "source"${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof source === "object" && !source.url) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing URL for "source" ${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// License must be a valid string or object or list
|
||||||
|
const licenses = [copyright.license].flat();
|
||||||
|
if (licenses.length === 0) {
|
||||||
|
throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
licenses.forEach((license) => {
|
||||||
|
if (!license) {
|
||||||
|
throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(copyright)})`);
|
||||||
|
}
|
||||||
|
if (typeof license !== "object" && typeof license !== "string") {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid "${typeof license}" type for "license"${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (typeof license === "object" && !(license.name || license.url)) {
|
||||||
|
throw new Error(
|
||||||
|
`Missing both name and URL for "license" ${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies attributions and copyright according to the [Creative Commons recommended practices](https://wiki.creativecommons.org/wiki/Recommended_practices_for_attribution)
|
||||||
* @param copyright Unparsed TOML copyright information.
|
* @param copyright Unparsed TOML copyright information.
|
||||||
*/
|
*/
|
||||||
function verifyAttributions(licenses: string) {
|
function verifyAttributions(licenses: string) {
|
||||||
const { copyright, attributions } = parse(licenses);
|
const { copyright, attributions } = parse(licenses);
|
||||||
// Make sure each copyright and attribution follows the TASL format.
|
// Make sure each copyright and attribution follows the TASL format,
|
||||||
// - T: title (or description)
|
// and that other fields (type, notes, items) pass their custom validation.
|
||||||
// - A: author
|
|
||||||
// - S: source
|
|
||||||
// - L: license
|
|
||||||
// - other fields that have custom validation: type, notes, items
|
|
||||||
[copyright, attributions].flat().forEach((value) => {
|
[copyright, attributions].flat().forEach((value) => {
|
||||||
// Title or description must be a valid string
|
// Validate TASL
|
||||||
const title = value.title ?? value.description;
|
validateTASL(value);
|
||||||
if (typeof title !== "string" || !title) {
|
const title = copyright.title ?? copyright.description;
|
||||||
throw new Error(`Missing "title" and/or "description" for attribution (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
// Author must be a valid string or object or list
|
|
||||||
const authors = [value.author].flat();
|
|
||||||
if (authors.length === 0) {
|
|
||||||
throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
authors.forEach((author) => {
|
|
||||||
if (!author) {
|
|
||||||
throw new Error(`Missing "author" for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
if (typeof author !== "object" && typeof author !== "string") {
|
|
||||||
throw new Error(`Invalid "${typeof author}" type for "author"${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
if (typeof author === "object" && !(author.name || author.url)) {
|
|
||||||
throw new Error(`Missing both name and URL for "author" ${JSON.stringify(author)} for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Source must be a valid string or list of strings
|
|
||||||
const sources = [value.source].flat();
|
|
||||||
if (sources.length === 0) {
|
|
||||||
throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
sources.forEach((source) => {
|
|
||||||
if (!source) {
|
|
||||||
throw new Error(`Missing "source" for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
if (typeof source !== "object" && typeof source !== "string") {
|
|
||||||
throw new Error(`Invalid "${typeof source}" type for "source"${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
if (typeof source === "object" && !(source.url)) {
|
|
||||||
throw new Error(`Missing URL for "source" ${JSON.stringify(source)} for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// License must be a valid string or object or list
|
|
||||||
const licenses = [value.license].flat();
|
|
||||||
if (licenses.length === 0) {
|
|
||||||
throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
licenses.forEach((license) => {
|
|
||||||
if (!license) {
|
|
||||||
throw new Error(`Missing "license" for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
if (typeof license !== "object" && typeof license !== "string") {
|
|
||||||
throw new Error(`Invalid "${typeof license}" type for "license"${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
if (typeof license === "object" && !(license.name || license.url)) {
|
|
||||||
throw new Error(`Missing both name and URL for "license" ${JSON.stringify(license)} for attribution "${title}" (${JSON.stringify(value)})`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Validate extra optional fields
|
// Validate extra optional fields
|
||||||
// 1. Type must be a valid string
|
// 1. Type must be a valid string
|
||||||
if (typeof value.type !== "string") {
|
if (typeof value.type !== "string") {
|
||||||
|
@ -80,13 +97,17 @@ function verifyAttributions(licenses: string) {
|
||||||
if ("items" in value) {
|
if ("items" in value) {
|
||||||
const items = value.items;
|
const items = value.items;
|
||||||
if (!Array.isArray(items)) {
|
if (!Array.isArray(items)) {
|
||||||
throw new Error(`Invalid non-array "items" ${JSON.stringify(items)} for attribution "${title}" (${JSON.stringify(value)})`);
|
throw new Error(
|
||||||
|
`Invalid non-array "items" ${JSON.stringify(items)} for attribution "${title}" (${JSON.stringify(value)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
throw new Error(`Invalid item ${JSON.stringify} in "items" for attribution "${title}" (${JSON.stringify(value)})`);
|
throw new Error(
|
||||||
|
`Invalid item ${JSON.stringify} in "items" for attribution "${title}" (${JSON.stringify(value)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
// 3. Type must be a valid string
|
// 3. Type must be a valid string
|
||||||
if ("notes" in value) {
|
if ("notes" in value) {
|
||||||
|
@ -106,18 +127,33 @@ function verifyAttributions(licenses: string) {
|
||||||
if ("additional" in copyright) {
|
if ("additional" in copyright) {
|
||||||
const additionals = copyright.additional;
|
const additionals = copyright.additional;
|
||||||
if (!Array.isArray(additionals)) {
|
if (!Array.isArray(additionals)) {
|
||||||
throw new Error(`Invalid non-array "additional" ${JSON.stringify(additionals)} for copyright (${JSON.stringify(copyright)})`);
|
throw new Error(
|
||||||
|
`Invalid non-array "additional" ${JSON.stringify(additionals)} for copyright (${JSON.stringify(copyright)})`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
additionals.forEach((additional) => {
|
additionals.forEach((additional) => {
|
||||||
if (typeof additional.notes !== "string" || !additional.notes) {
|
if (typeof additional.notes !== "string" || !additional.notes) {
|
||||||
throw new Error(`Invalid "notes" for additional copyright (${JSON.stringify(additional)})`);
|
throw new Error(`Invalid "notes" for additional copyright (${JSON.stringify(additional)})`);
|
||||||
}
|
}
|
||||||
})
|
if (typeof additional.type !== "string" || !additional.type) {
|
||||||
|
throw new Error(`Invalid "type" for additional copyright (${JSON.stringify(additional)})`);
|
||||||
|
}
|
||||||
|
// Check TASL + date if title or description is present
|
||||||
|
if (additional.title || additional.description) {
|
||||||
|
validateTASL(additional);
|
||||||
|
if (typeof additional.date !== "string") {
|
||||||
|
throw new Error(`Invalid "date" for additional (${JSON.stringify(additional)})`);
|
||||||
|
}
|
||||||
|
if (!additional.date) {
|
||||||
|
throw new Error(`Missing "date" for additional (${JSON.stringify(additional)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
export const GET: APIRoute = async () => {
|
||||||
const licenses = await readFile("./src/data/licenses.toml", { encoding: "utf-8" });
|
const licenses = await readFile("./src/data/licenses.toml", { encoding: "utf-8" });
|
||||||
verifyAttributions(licenses);
|
verifyAttributions(licenses);
|
||||||
return new Response(licenses, { headers: { "Content-Type": "application/toml; charset=utf-8" } })
|
return new Response(licenses, { headers: { "Content-Type": "application/toml; charset=utf-8" } });
|
||||||
};
|
};
|
||||||
|
|
|
@ -113,7 +113,7 @@ const totalPages = Math.ceil(page.total / page.size);
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sr-only">
|
<div class="sr-only select-none">
|
||||||
<p class="p-category" aria-label="Category">
|
<p class="p-category" aria-label="Category">
|
||||||
Story
|
Story
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -177,7 +177,7 @@ const totalWorksWithTag = t(
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sr-only">
|
<div class="sr-only select-none">
|
||||||
<p class="p-category" aria-label="Category">
|
<p class="p-category" aria-label="Category">
|
||||||
Story
|
Story
|
||||||
</p>
|
</p>
|
||||||
|
@ -235,7 +235,7 @@ const totalWorksWithTag = t(
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="sr-only">
|
<div class="sr-only select-none">
|
||||||
<p class="p-category" aria-label="Category">
|
<p class="p-category" aria-label="Category">
|
||||||
Game
|
Game
|
||||||
</p>
|
</p>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue