Several minor improvements to typing and misc.
- Improved schema validation - Move username parsing and other validators to schema types - Fix astro check command - Add JSON/YAML schema validation for data collections - Update licenses - Remove deployment script in favor of rsync - Prevent unsanitized input in export-story script - Change "eng" language to "en", per BCP47 - Clean up i18n keys and add aria attributes - Improve MastodonComments behavior on no-JS browsers
This commit is contained in:
parent
fe908a4989
commit
7bb8a952ef
54 changed files with 1005 additions and 962 deletions
|
|
@ -54,11 +54,13 @@
|
|||
(function () {
|
||||
if (localStorage.getItem("ageVerified") !== "true") {
|
||||
document.body.appendChild(
|
||||
(document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true),
|
||||
document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")!
|
||||
.content.cloneNode(true),
|
||||
);
|
||||
const modal = document.querySelector<HTMLDivElement>("body > #modal-age-restricted")!;
|
||||
const rejectButton = modal.querySelector<HTMLButtonElement>("button[data-modal-reject]")!;
|
||||
const acceptButton = modal.querySelector<HTMLButtonElement>("button[data-modal-accept]")!;
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type Lang } from "../content/config";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,4 +12,12 @@ const authors = Astro.slots.has("default")
|
|||
: [];
|
||||
---
|
||||
|
||||
{authors.length ? <p id="authors" set:html={t(lang, "story/authors", authors)} /> : null}
|
||||
{
|
||||
authors.length ? (
|
||||
<p
|
||||
id="authors"
|
||||
aria-label={t(lang, "story/authors_aria_label", authors)}
|
||||
set:html={t(lang, "story/authors", authors)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type Lang } from "../content/config";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,4 +12,12 @@ const commissioners = Astro.slots.has("default")
|
|||
: [];
|
||||
---
|
||||
|
||||
{commissioners.length ? <p id="commissioners" set:html={t(lang, "story/commissioned_by", commissioners)} /> : null}
|
||||
{
|
||||
commissioners.length ? (
|
||||
<p
|
||||
id="commissioners"
|
||||
aria-label={t(lang, "story/commissioners_aria_label", commissioners)}
|
||||
set:html={t(lang, "story/commissioned_by", commissioners)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { type CollectionEntry } from "astro:content";
|
||||
import { type Lang } from "../content/config";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
import UserComponent from "./UserComponent.astro";
|
||||
import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro";
|
||||
|
|
@ -15,7 +15,7 @@ const { copyrightedCharacters, lang } = Astro.props;
|
|||
|
||||
{
|
||||
copyrightedCharacters ? (
|
||||
<section id="copyrighted-characters">
|
||||
<section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}>
|
||||
<ul>
|
||||
{copyrightedCharacters.map(([owner, characterList]) => (
|
||||
<CopyrightedCharactersItem
|
||||
|
|
|
|||
|
|
@ -6,12 +6,12 @@
|
|||
|
||||
<script>
|
||||
(function () {
|
||||
var colorScheme = localStorage.getItem("colorScheme");
|
||||
let colorScheme = localStorage.getItem("colorScheme");
|
||||
if (colorScheme == null || colorScheme === "auto") {
|
||||
colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
document.querySelectorAll("button[data-dark-mode]").forEach(function (button) {
|
||||
button.addEventListener("click", function (e) {
|
||||
document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
|
||||
button.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (colorScheme === "dark") {
|
||||
colorScheme = "light";
|
||||
|
|
|
|||
|
|
@ -1,37 +1,47 @@
|
|||
---
|
||||
import type { Lang } from "../content/config";
|
||||
|
||||
type Props = {
|
||||
instance?: string;
|
||||
user?: string;
|
||||
postId?: string;
|
||||
lang: Lang;
|
||||
link: string;
|
||||
instance: string;
|
||||
user: string;
|
||||
postId: string;
|
||||
};
|
||||
|
||||
const { instance, user, postId } = Astro.props;
|
||||
const { link, instance, user, postId } = Astro.props;
|
||||
---
|
||||
|
||||
<section
|
||||
id="comment-section"
|
||||
class="hidden px-2 font-serif"
|
||||
class="px-2 font-serif"
|
||||
aria-describedby="title-comment-section"
|
||||
data-instance={instance || ""}
|
||||
data-user={user || ""}
|
||||
data-post-id={postId || ""}
|
||||
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">
|
||||
Comments
|
||||
</h2>
|
||||
<div class="text-stone-800 dark:text-stone-100" id="comments">
|
||||
<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"
|
||||
id="load-comments-button"
|
||||
data-load-comments
|
||||
>
|
||||
<span>Click to load comments</span>
|
||||
</button>
|
||||
<p class="my-1">
|
||||
<a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template id="template-comments-loading">
|
||||
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<template id="template-button">
|
||||
<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"
|
||||
id="load-comments-button"
|
||||
>
|
||||
<span>Click to load comments</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template id="template-button-loading">
|
||||
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden>
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path
|
||||
class="opacity-100"
|
||||
|
|
@ -45,12 +55,16 @@ const { instance, user, postId } = Astro.props;
|
|||
<template id="template-comment-box">
|
||||
<div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800">
|
||||
<div class="ml-1">
|
||||
<a data-author class="text-link flex items-center text-lg hover:underline focus:underline">
|
||||
<a data-author class="text-link flex items-center text-lg hover:underline focus:underline" target="_blank">
|
||||
<img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
|
||||
<span data-display-name></span>
|
||||
</a>
|
||||
<a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline">
|
||||
<span class="mr-1" data-publish-date></span>
|
||||
<a
|
||||
data-post-link
|
||||
class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
|
||||
target="_blank"
|
||||
>
|
||||
<span class="mr-1" data-publish-date aria-label="Publish date"></span>
|
||||
</a>
|
||||
</div>
|
||||
<div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
|
||||
|
|
@ -72,11 +86,18 @@ const { instance, user, postId } = Astro.props;
|
|||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div data-comment-thread class="-mb-2"></div>
|
||||
<div data-comment-thread class="-mb-2" aria-hidden></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
interface Post {
|
||||
link: string;
|
||||
instance: string;
|
||||
user: string;
|
||||
postId: string;
|
||||
}
|
||||
|
||||
interface Emoji {
|
||||
shortcode: string;
|
||||
url: string;
|
||||
|
|
@ -104,115 +125,131 @@ const { instance, user, postId } = Astro.props;
|
|||
emojis: Emoji[];
|
||||
}
|
||||
|
||||
interface ApiResponse {
|
||||
ancestors: Comment[];
|
||||
descendants: Comment[];
|
||||
}
|
||||
|
||||
(function () {
|
||||
const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) =>
|
||||
emojis.reduce(
|
||||
function replaceEmojis(text: string, emojis: Emoji[]) {
|
||||
return emojis.reduce(
|
||||
(acc, emoji) =>
|
||||
acc.replaceAll(
|
||||
`:${emoji.shortcode}:`,
|
||||
`<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
|
||||
`<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
|
||||
),
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
const commentSection = document.querySelector<Element>("#comment-section")!;
|
||||
const instance = commentSection.getAttribute("data-instance");
|
||||
const user = commentSection.getAttribute("data-user");
|
||||
const postId = commentSection.getAttribute("data-post-id");
|
||||
if (instance && user && postId) {
|
||||
commentSection.classList.remove("hidden");
|
||||
commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => {
|
||||
async function renderComments(section: Element, post: Post) {
|
||||
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 commentsList: HTMLElement[] = [];
|
||||
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;
|
||||
|
||||
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
|
||||
commentBoxAuthor.href = comment.account.url;
|
||||
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
|
||||
avatar.src = comment.account.avatar;
|
||||
avatar.alt = `Profile picture of ${comment.account.username}`;
|
||||
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
|
||||
displayName.innerHTML = 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["span"]>("span[data-publish-date]")!;
|
||||
publishDate.innerText = 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("span");
|
||||
edited.className = "italic";
|
||||
edited.innerText = "(edited)";
|
||||
commentBoxPostLink.appendChild(edited);
|
||||
}
|
||||
|
||||
const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
|
||||
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
|
||||
|
||||
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
|
||||
commentBoxFavorites.innerText = comment.favourites_count.toString();
|
||||
|
||||
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
|
||||
commentBoxReblogs.innerText = comment.reblogs_count.toString();
|
||||
|
||||
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
|
||||
commentMap[comment.id] = commentsList.length;
|
||||
commentsList.push(commentBox);
|
||||
} else {
|
||||
const commentsIndex = commentMap[comment.in_reply_to_id];
|
||||
commentMap[comment.id] = commentsIndex;
|
||||
const parentThreadDiv =
|
||||
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
|
||||
parentThreadDiv.removeAttribute("aria-hidden");
|
||||
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>`;
|
||||
} 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);
|
||||
}
|
||||
} catch (e) {
|
||||
commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
|
||||
console.error("Fetch Mastodon comments error", e);
|
||||
}
|
||||
}
|
||||
|
||||
function initCommentSection() {
|
||||
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section");
|
||||
if (!commentSection) {
|
||||
return;
|
||||
}
|
||||
const post = {
|
||||
link: commentSection.dataset.link,
|
||||
instance: commentSection.dataset.instance,
|
||||
user: commentSection.dataset.user,
|
||||
postId: commentSection.dataset.postId,
|
||||
};
|
||||
if (!post.link || !post.instance || !post.user || !post.postId) {
|
||||
return;
|
||||
}
|
||||
const loadCommentsButton = document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!
|
||||
.content.cloneNode(true) as HTMLButtonElement;
|
||||
commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!.replaceChildren(loadCommentsButton);
|
||||
loadCommentsButton.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const loadCommentsButton = e.target as HTMLButtonElement;
|
||||
loadCommentsButton.setAttribute("disabled", "true");
|
||||
loadCommentsButton.replaceChildren(
|
||||
(document.getElementById("template-comments-loading") as HTMLTemplateElement).content.cloneNode(true),
|
||||
document
|
||||
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
|
||||
.content.cloneNode(true),
|
||||
);
|
||||
const renderComments = async () => {
|
||||
try {
|
||||
if (!instance || !user || !postId) {
|
||||
throw new Error(
|
||||
`Cannot fetch comments without all fields (instance=${instance}, user=${user}, post-id=${postId})`,
|
||||
);
|
||||
}
|
||||
const response = await fetch(`https://${instance}/api/v1/statuses/${postId}/context`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
|
||||
}
|
||||
const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json();
|
||||
|
||||
const commentsList: HTMLElement[] = [];
|
||||
const commentMap: Record<string, number> = {};
|
||||
const commentTemplate = document.getElementById("template-comment-box") as HTMLTemplateElement;
|
||||
data.descendants.forEach((comment) => {
|
||||
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
|
||||
|
||||
const commentBoxAuthor = commentBox.querySelector<HTMLAnchorElement>("[data-author]")!;
|
||||
commentBoxAuthor.href = comment.account.url;
|
||||
commentBoxAuthor.target = "_blank";
|
||||
const avatar = commentBoxAuthor.querySelector<HTMLImageElement>("[data-avatar]")!;
|
||||
avatar.src = comment.account.avatar;
|
||||
avatar.alt = `Profile picture of ${comment.account.username}`;
|
||||
const displayName = commentBoxAuthor.querySelector<HTMLSpanElement>("[data-display-name]")!;
|
||||
displayName.innerHTML = replaceEmojis(
|
||||
comment.account.display_name,
|
||||
comment.account.emojis,
|
||||
"inline mx-[1px] w-5",
|
||||
);
|
||||
|
||||
const commentBoxPostLink = commentBox.querySelector<HTMLAnchorElement>("[data-post-link]")!;
|
||||
commentBoxPostLink.href = comment.url;
|
||||
commentBoxPostLink.target = "_blank";
|
||||
const publishDate = commentBoxPostLink.querySelector<HTMLSpanElement>("[data-publish-date]")!;
|
||||
publishDate.innerText = 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("span");
|
||||
edited.className = "italic";
|
||||
edited.innerText = "(edited)";
|
||||
commentBoxPostLink.appendChild(edited);
|
||||
}
|
||||
|
||||
const commentBoxContent = commentBox.querySelector<HTMLDivElement>("[data-content]")!;
|
||||
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis, "inline mx-[1px] w-5");
|
||||
|
||||
const commentBoxFavorites = commentBox.querySelector<HTMLSpanElement>("[data-favorites]")!;
|
||||
commentBoxFavorites.innerText = comment.favourites_count.toString();
|
||||
|
||||
const commentBoxReblogs = commentBox.querySelector<HTMLSpanElement>("[data-reblogs]")!;
|
||||
commentBoxReblogs.innerText = comment.reblogs_count.toString();
|
||||
|
||||
if (comment.in_reply_to_id === postId || !(comment.in_reply_to_id in commentMap)) {
|
||||
commentMap[comment.id] = commentsList.length;
|
||||
commentsList.push(commentBox);
|
||||
} else {
|
||||
const commentsIndex = commentMap[comment.in_reply_to_id];
|
||||
commentMap[comment.id] = commentsIndex;
|
||||
commentsList[commentsIndex]
|
||||
.querySelector<HTMLDivElement>("[data-comment-thread]")!
|
||||
.appendChild(commentBox);
|
||||
}
|
||||
});
|
||||
const commentsDiv = commentSection.querySelector<HTMLDivElement>("#comments")!;
|
||||
if (commentsList.length === 0) {
|
||||
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">Be the first to join the conversation on Mastodon</a>.</p>`;
|
||||
} else {
|
||||
commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">by replying on Mastodon</a>.</p>`;
|
||||
commentsDiv.append(...commentsList);
|
||||
}
|
||||
} catch (e) {
|
||||
loadCommentsButton.innerHTML = `<span>Unable to load comments.</span>`;
|
||||
console.error("Fetch Mastodon comments error", e);
|
||||
}
|
||||
};
|
||||
renderComments();
|
||||
renderComments(commentSection, post as Post);
|
||||
});
|
||||
}
|
||||
|
||||
initCommentSection();
|
||||
})();
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
---
|
||||
import { type Lang } from "../content/config";
|
||||
import type { Lang } from "../content/config";
|
||||
import { t } from "../i18n";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,4 +12,12 @@ const requesters = Astro.slots.has("default")
|
|||
: [];
|
||||
---
|
||||
|
||||
{requesters.length ? <p id="requesters" set:html={t(lang, "story/requested_by", requesters)} /> : null}
|
||||
{
|
||||
requesters.length ? (
|
||||
<p
|
||||
id="requesters"
|
||||
aria-label={t(lang, "story/requesters_aria_label", requesters)}
|
||||
set:html={t(lang, "story/requested_by", requesters)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
import { type CollectionEntry } from "astro:content";
|
||||
import { type Lang } from "../content/config";
|
||||
import type { CollectionEntry } from "astro:content";
|
||||
import type { Lang } from "../content/config";
|
||||
import { getUsernameForLang } from "../utils/get_username_for_lang";
|
||||
|
||||
type Props = {
|
||||
|
|
@ -12,12 +12,7 @@ let { user, lang } = Astro.props;
|
|||
const username = getUsernameForLang(user, lang);
|
||||
let link: string | null = null;
|
||||
if (user.data.preferredLink) {
|
||||
const preferredLink = user.data.links[user.data.preferredLink]!;
|
||||
if (typeof preferredLink === "string") {
|
||||
link = preferredLink;
|
||||
} else {
|
||||
link = preferredLink[0];
|
||||
}
|
||||
link = user.data.links[user.data.preferredLink]!.link;
|
||||
}
|
||||
---
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue