feat: add shareable meme permalinks
This commit is contained in:
@@ -54,6 +54,7 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public
|
|||||||
|
|
||||||
- `GET /api/memes?page=1&pageSize=12`: lists approved memes only. `pageSize` is capped at `48`.
|
- `GET /api/memes?page=1&pageSize=12`: lists approved memes only. `pageSize` is capped at `48`.
|
||||||
- `GET /api/memes/<sha256>`: returns public metadata for one approved meme.
|
- `GET /api/memes/<sha256>`: returns public metadata for one approved meme.
|
||||||
|
- `GET /meme/<sha256>`: opens an approved meme in the viewer and provides its shareable permalink.
|
||||||
- `GET /media/<sha256>`: returns the normalized WebP image for one approved meme.
|
- `GET /media/<sha256>`: returns the normalized WebP image for one approved meme.
|
||||||
- `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`.
|
- `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`.
|
||||||
|
|
||||||
|
|||||||
+133
-7
@@ -19,15 +19,23 @@ const submitUpload = document.querySelector('#submit-upload');
|
|||||||
const lightbox = document.querySelector('#lightbox');
|
const lightbox = document.querySelector('#lightbox');
|
||||||
const lightboxImage = document.querySelector('#lightbox-image');
|
const lightboxImage = document.querySelector('#lightbox-image');
|
||||||
const lightboxDownload = document.querySelector('#lightbox-download');
|
const lightboxDownload = document.querySelector('#lightbox-download');
|
||||||
|
const lightboxShare = document.querySelector('#lightbox-share');
|
||||||
const lightboxId = document.querySelector('#lightbox-id');
|
const lightboxId = document.querySelector('#lightbox-id');
|
||||||
|
const shareModal = document.querySelector('#share-modal');
|
||||||
|
const shareLink = document.querySelector('#share-link');
|
||||||
|
const shareStatus = document.querySelector('#share-status');
|
||||||
|
const copyShareLinkButton = document.querySelector('#copy-share-link');
|
||||||
const scrollIndicator = document.querySelector('#scroll-indicator');
|
const scrollIndicator = document.querySelector('#scroll-indicator');
|
||||||
|
|
||||||
let currentPage = 0;
|
let currentPage = 0;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let latestValidationRun = 0;
|
let latestValidationRun = 0;
|
||||||
|
let activeShareTrigger = null;
|
||||||
|
let shareStatusTimer = null;
|
||||||
const viewedThisSession = new Set();
|
const viewedThisSession = new Set();
|
||||||
const pendingViewTimers = new Map();
|
const pendingViewTimers = new Map();
|
||||||
|
const copiedFeedbackTimers = new WeakMap();
|
||||||
|
|
||||||
document.querySelector('#open-upload').addEventListener('click', () => {
|
document.querySelector('#open-upload').addEventListener('click', () => {
|
||||||
formStatus.textContent = '';
|
formStatus.textContent = '';
|
||||||
@@ -36,6 +44,11 @@ document.querySelector('#open-upload').addEventListener('click', () => {
|
|||||||
document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
||||||
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
||||||
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
|
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
|
||||||
|
document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
|
||||||
|
copyShareLinkButton.addEventListener('click', copyActiveShareLink);
|
||||||
|
lightboxShare.addEventListener('click', async () => {
|
||||||
|
await openShareDialog(lightboxShare.dataset.shareId, lightboxShare);
|
||||||
|
});
|
||||||
|
|
||||||
fileInput.addEventListener('change', async () => {
|
fileInput.addEventListener('change', async () => {
|
||||||
const validationRun = ++latestValidationRun;
|
const validationRun = ++latestValidationRun;
|
||||||
@@ -82,15 +95,21 @@ uploadForm.addEventListener('submit', async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
grid.addEventListener('click', async (event) => {
|
grid.addEventListener('click', async (event) => {
|
||||||
|
const shareButton = event.target.closest('[data-share-id]');
|
||||||
|
if (shareButton) {
|
||||||
|
await openShareDialog(shareButton.dataset.shareId, shareButton);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const viewButton = event.target.closest('[data-view-id]');
|
const viewButton = event.target.closest('[data-view-id]');
|
||||||
if (!viewButton) return;
|
if (!viewButton) return;
|
||||||
const card = viewButton.closest('.meme-card');
|
const card = viewButton.closest('.meme-card');
|
||||||
lightboxImage.src = viewButton.dataset.viewUrl;
|
await openLightbox({
|
||||||
lightboxImage.alt = card.querySelector('img').alt;
|
id: viewButton.dataset.viewId,
|
||||||
lightboxDownload.href = viewButton.dataset.downloadUrl;
|
url: viewButton.dataset.viewUrl,
|
||||||
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
|
downloadUrl: viewButton.dataset.downloadUrl,
|
||||||
lightbox.showModal();
|
alt: card.querySelector('img').alt
|
||||||
await recordViewOnce(viewButton.dataset.viewId);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -109,6 +128,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
|||||||
window.addEventListener('resize', updateScrollIndicator);
|
window.addEventListener('resize', updateScrollIndicator);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
window.setInterval(refreshStatus, 5000);
|
window.setInterval(refreshStatus, 5000);
|
||||||
|
await openSharedMemeFromUrl();
|
||||||
await loadMemes(1, { reset: true });
|
await loadMemes(1, { reset: true });
|
||||||
observer.observe(feedLoader);
|
observer.observe(feedLoader);
|
||||||
|
|
||||||
@@ -170,7 +190,8 @@ function renderMemes(memes, options = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="action-group">
|
<div class="action-group">
|
||||||
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
|
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
|
||||||
<button class="text-action text-action-accent icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
<button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
|
||||||
|
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -187,6 +208,111 @@ function renderMemes(memes, options = {}) {
|
|||||||
observeViewedImages(cards);
|
observeViewedImages(cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openSharedMemeFromUrl() {
|
||||||
|
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
||||||
|
if (!match) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/memes/${match[1]}`);
|
||||||
|
const payload = await response.json();
|
||||||
|
if (!response.ok) throw new Error(payload.error || 'Meme not found.');
|
||||||
|
await openLightbox({
|
||||||
|
...payload.meme,
|
||||||
|
alt: `Shared meme ${shortId(payload.meme.id)}`
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
window.location.replace('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLightbox({ id, url, downloadUrl, alt }) {
|
||||||
|
lightboxImage.src = url;
|
||||||
|
lightboxImage.alt = alt;
|
||||||
|
lightboxDownload.href = downloadUrl;
|
||||||
|
lightboxShare.dataset.shareId = id;
|
||||||
|
lightboxId.textContent = `ID: ${shortId(id)}`;
|
||||||
|
if (!lightbox.open) lightbox.showModal();
|
||||||
|
await recordViewOnce(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openShareDialog(id, triggerButton = null) {
|
||||||
|
if (!isMemeId(id)) return;
|
||||||
|
activeShareTrigger = triggerButton;
|
||||||
|
shareLink.value = new URL(`/meme/${id}`, window.location.origin).href;
|
||||||
|
window.clearTimeout(shareStatusTimer);
|
||||||
|
shareStatusTimer = null;
|
||||||
|
shareStatus.textContent = '';
|
||||||
|
resetCopiedFeedback(copyShareLinkButton);
|
||||||
|
if (!shareModal.open) shareModal.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyActiveShareLink() {
|
||||||
|
if (!shareLink.value) return;
|
||||||
|
copyShareLinkButton.disabled = true;
|
||||||
|
try {
|
||||||
|
await copyText(shareLink.value);
|
||||||
|
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
|
||||||
|
showCopiedFeedback(copyShareLinkButton);
|
||||||
|
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger);
|
||||||
|
window.clearTimeout(shareStatusTimer);
|
||||||
|
shareStatusTimer = window.setTimeout(() => {
|
||||||
|
shareStatus.textContent = '';
|
||||||
|
shareStatusTimer = null;
|
||||||
|
}, 2600);
|
||||||
|
} catch {
|
||||||
|
shareLink.focus();
|
||||||
|
shareLink.select();
|
||||||
|
shareStatus.textContent = 'COPY_UNAVAILABLE — SELECT_AND_COPY_THE_LINK';
|
||||||
|
} finally {
|
||||||
|
copyShareLinkButton.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCopiedFeedback(button) {
|
||||||
|
const icon = button.querySelector('.material-symbols-outlined');
|
||||||
|
const label = button.querySelector('[data-copy-label]');
|
||||||
|
if (!icon) return;
|
||||||
|
|
||||||
|
button.dataset.defaultIcon ||= icon.textContent.trim();
|
||||||
|
if (label) button.dataset.defaultLabel ||= label.textContent.trim();
|
||||||
|
button.dataset.defaultAriaLabel ||= button.getAttribute('aria-label') || '';
|
||||||
|
button.dataset.defaultTitle ||= button.getAttribute('title') || '';
|
||||||
|
|
||||||
|
icon.textContent = 'check';
|
||||||
|
if (label) label.textContent = 'COPIED';
|
||||||
|
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', 'Share link copied');
|
||||||
|
if (button.dataset.defaultTitle) button.setAttribute('title', 'Share link copied');
|
||||||
|
button.classList.add('is-copied');
|
||||||
|
|
||||||
|
window.clearTimeout(copiedFeedbackTimers.get(button));
|
||||||
|
copiedFeedbackTimers.set(button, window.setTimeout(() => {
|
||||||
|
resetCopiedFeedback(button);
|
||||||
|
}, 2600));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCopiedFeedback(button) {
|
||||||
|
const icon = button.querySelector('.material-symbols-outlined');
|
||||||
|
const label = button.querySelector('[data-copy-label]');
|
||||||
|
window.clearTimeout(copiedFeedbackTimers.get(button));
|
||||||
|
copiedFeedbackTimers.delete(button);
|
||||||
|
if (icon && button.dataset.defaultIcon) icon.textContent = button.dataset.defaultIcon;
|
||||||
|
if (label && button.dataset.defaultLabel) label.textContent = button.dataset.defaultLabel;
|
||||||
|
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', button.dataset.defaultAriaLabel);
|
||||||
|
if (button.dataset.defaultTitle) button.setAttribute('title', button.dataset.defaultTitle);
|
||||||
|
button.classList.remove('is-copied');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(value) {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
shareLink.focus();
|
||||||
|
shareLink.select();
|
||||||
|
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.');
|
||||||
|
}
|
||||||
|
|
||||||
async function validateClientFile(file) {
|
async function validateClientFile(file) {
|
||||||
if (!file) return 'SELECT A FILE.';
|
if (!file) return 'SELECT A FILE.';
|
||||||
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
|
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
|
||||||
|
|||||||
@@ -339,12 +339,14 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-action {
|
.text-action {
|
||||||
|
appearance: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -371,6 +373,12 @@ a {
|
|||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-action.is-copied {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgb(0 255 65 / 12%);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.feed-loader {
|
.feed-loader {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -523,6 +531,10 @@ dialog::backdrop {
|
|||||||
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
|
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-panel {
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header,
|
.modal-header,
|
||||||
.lightbox-toolbar {
|
.lightbox-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -576,6 +588,60 @@ dialog::backdrop {
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-body {
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-body label,
|
||||||
|
.share-status {
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: var(--surface-lowest);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row input:focus {
|
||||||
|
border-color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-status {
|
||||||
|
min-height: 16px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -641,12 +707,11 @@ dialog::backdrop {
|
|||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightbox-download {
|
.lightbox-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: max-content;
|
justify-content: flex-end;
|
||||||
margin: 12px 12px 12px auto;
|
gap: 8px;
|
||||||
align-items: center;
|
margin: 12px;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -752,6 +817,15 @@ dialog::backdrop {
|
|||||||
padding-inline: 14px;
|
padding-inline: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-link-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
padding-top: 84px;
|
padding-top: 84px;
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-1
@@ -108,7 +108,30 @@
|
|||||||
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
|
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
|
||||||
</div>
|
</div>
|
||||||
<img id="lightbox-image" alt="">
|
<img id="lightbox-image" alt="">
|
||||||
<a class="primary-action lightbox-download" id="lightbox-download" href="#"><span class="material-symbols-outlined" aria-hidden="true">download</span><span>DOWNLOAD</span></a>
|
<div class="lightbox-actions">
|
||||||
|
<a class="text-action icon-only-action" id="lightbox-download" href="#" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
|
||||||
|
<button class="text-action icon-only-action" id="lightbox-share" type="button" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<dialog class="modal" id="share-modal" aria-labelledby="share-title">
|
||||||
|
<div class="modal-panel share-panel">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="share-title">SHARE_MEME</h2>
|
||||||
|
<button class="icon-action" id="close-share" type="button" aria-label="Close">X</button>
|
||||||
|
</div>
|
||||||
|
<div class="share-body">
|
||||||
|
<label for="share-link">MEME_LINK</label>
|
||||||
|
<div class="share-link-row">
|
||||||
|
<input id="share-link" type="text" readonly spellcheck="false" aria-describedby="share-status">
|
||||||
|
<button class="primary-action share-copy" id="copy-share-link" type="button">
|
||||||
|
<span class="material-symbols-outlined" data-copy-icon aria-hidden="true">content_copy</span>
|
||||||
|
<span data-copy-label>COPY</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="share-status" id="share-status" role="status" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</dialog>
|
</dialog>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -75,6 +75,21 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
|
||||||
|
if (req.method === 'GET' && memePageMatch) {
|
||||||
|
const meme = store.get(memePageMatch[1]);
|
||||||
|
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
|
const nonce = crypto.randomBytes(16).toString('base64');
|
||||||
|
withSecurityHeaders(res, { scriptNonce: nonce });
|
||||||
|
return sendHtml(res, 200, renderIndex({
|
||||||
|
template: indexTemplate,
|
||||||
|
baseUrl,
|
||||||
|
nonce,
|
||||||
|
approvedCount: store.count('approved'),
|
||||||
|
meme: publicMeme(meme)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/openapi.json') {
|
if (req.method === 'GET' && url.pathname === '/openapi.json') {
|
||||||
res.setHeader('Cache-Control', 'public, max-age=300');
|
res.setHeader('Cache-Control', 'public, max-age=300');
|
||||||
return sendJson(res, 200, openApiSpecFor(baseUrl));
|
return sendJson(res, 200, openApiSpecFor(baseUrl));
|
||||||
|
|||||||
+38
-20
@@ -13,36 +13,54 @@ export function publicBaseUrl(req) {
|
|||||||
return cleanBase(`${proto}://${host || 'localhost:8080'}`);
|
return cleanBase(`${proto}://${host || 'localhost:8080'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderIndex({ template, baseUrl, nonce, approvedCount }) {
|
export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null }) {
|
||||||
const canonical = `${baseUrl}/`;
|
const home = `${baseUrl}/`;
|
||||||
const image = `${baseUrl}/assets/yoonect-logo.png`;
|
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : home;
|
||||||
const jsonLd = {
|
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}/assets/yoonect-logo.png`;
|
||||||
'@context': 'https://schema.org',
|
const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : SITE_NAME;
|
||||||
'@graph': [
|
const description = meme
|
||||||
{
|
? `View and share meme ${shortId(meme.id)} on ${SITE_NAME}.`
|
||||||
'@type': 'WebSite',
|
: SITE_DESCRIPTION;
|
||||||
'@id': `${canonical}#website`,
|
const pageMetadata = meme
|
||||||
name: SITE_NAME,
|
? {
|
||||||
|
'@type': 'ImageObject',
|
||||||
|
'@id': `${canonical}#meme`,
|
||||||
|
name: `Meme ${shortId(meme.id)}`,
|
||||||
|
contentUrl: image,
|
||||||
url: canonical,
|
url: canonical,
|
||||||
description: SITE_DESCRIPTION,
|
uploadDate: meme.createdAt,
|
||||||
inLanguage: 'en'
|
width: meme.width,
|
||||||
},
|
height: meme.height,
|
||||||
{
|
isPartOf: { '@id': `${home}#website` }
|
||||||
|
}
|
||||||
|
: {
|
||||||
'@type': 'CollectionPage',
|
'@type': 'CollectionPage',
|
||||||
'@id': `${canonical}#collection`,
|
'@id': `${canonical}#collection`,
|
||||||
name: SITE_NAME,
|
name: SITE_NAME,
|
||||||
url: canonical,
|
url: canonical,
|
||||||
description: SITE_DESCRIPTION,
|
description: SITE_DESCRIPTION,
|
||||||
isPartOf: { '@id': `${canonical}#website` },
|
isPartOf: { '@id': `${home}#website` },
|
||||||
about: ['memes', 'internet culture', 'image gallery', 'community moderation'],
|
about: ['memes', 'internet culture', 'image gallery', 'community moderation'],
|
||||||
numberOfItems: approvedCount
|
numberOfItems: approvedCount
|
||||||
|
};
|
||||||
|
const jsonLd = {
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': `${home}#website`,
|
||||||
|
name: SITE_NAME,
|
||||||
|
url: home,
|
||||||
|
description: SITE_DESCRIPTION,
|
||||||
|
inLanguage: 'en'
|
||||||
},
|
},
|
||||||
|
pageMetadata,
|
||||||
{
|
{
|
||||||
'@type': 'SoftwareApplication',
|
'@type': 'SoftwareApplication',
|
||||||
name: SITE_NAME,
|
name: SITE_NAME,
|
||||||
applicationCategory: 'MultimediaApplication',
|
applicationCategory: 'MultimediaApplication',
|
||||||
operatingSystem: 'Web',
|
operatingSystem: 'Web',
|
||||||
url: canonical,
|
url: home,
|
||||||
codeRepository: REPO_URL,
|
codeRepository: REPO_URL,
|
||||||
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }
|
||||||
}
|
}
|
||||||
@@ -50,8 +68,8 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return template
|
return template
|
||||||
.replaceAll('__SEO_TITLE__', escapeHtml(SITE_NAME))
|
.replaceAll('__SEO_TITLE__', escapeHtml(title))
|
||||||
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(SITE_DESCRIPTION))
|
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(description))
|
||||||
.replaceAll('__SEO_CANONICAL__', canonical)
|
.replaceAll('__SEO_CANONICAL__', canonical)
|
||||||
.replaceAll('__SEO_IMAGE__', image)
|
.replaceAll('__SEO_IMAGE__', image)
|
||||||
.replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd)))
|
.replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd)))
|
||||||
@@ -125,7 +143,7 @@ export function feedJson(baseUrl, memes) {
|
|||||||
description: SITE_DESCRIPTION,
|
description: SITE_DESCRIPTION,
|
||||||
items: memes.slice(0, 50).map((meme) => ({
|
items: memes.slice(0, 50).map((meme) => ({
|
||||||
id: meme.id,
|
id: meme.id,
|
||||||
url: `${baseUrl}/media/${meme.id}`,
|
url: `${baseUrl}/meme/${meme.id}`,
|
||||||
image: `${baseUrl}/media/${meme.id}`,
|
image: `${baseUrl}/media/${meme.id}`,
|
||||||
title: `Meme ${shortId(meme.id)}`,
|
title: `Meme ${shortId(meme.id)}`,
|
||||||
content_text: `Approved meme with MEME_CONSENSUS_SCORE ${meme.moderationScore}/100.`,
|
content_text: `Approved meme with MEME_CONSENSUS_SCORE ${meme.moderationScore}/100.`,
|
||||||
@@ -145,7 +163,7 @@ export function sitemapXml(baseUrl, memes) {
|
|||||||
].join('\n'),
|
].join('\n'),
|
||||||
...memes.slice(0, 1000).map((meme) => [
|
...memes.slice(0, 1000).map((meme) => [
|
||||||
' <url>',
|
' <url>',
|
||||||
` <loc>${xmlEscape(`${baseUrl}/media/${meme.id}`)}</loc>`,
|
` <loc>${xmlEscape(`${baseUrl}/meme/${meme.id}`)}</loc>`,
|
||||||
` <lastmod>${xmlEscape(meme.createdAt)}</lastmod>`,
|
` <lastmod>${xmlEscape(meme.createdAt)}</lastmod>`,
|
||||||
' </url>'
|
' </url>'
|
||||||
].join('\n'))
|
].join('\n'))
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { feedJson, renderIndex, sitemapXml } from '../src/seo.js';
|
||||||
|
|
||||||
|
const id = 'a'.repeat(64);
|
||||||
|
const meme = {
|
||||||
|
id,
|
||||||
|
createdAt: '2026-07-24T12:00:00.000Z',
|
||||||
|
width: 1200,
|
||||||
|
height: 900,
|
||||||
|
moderationScore: 92
|
||||||
|
};
|
||||||
|
const template = [
|
||||||
|
'<title>__SEO_TITLE__</title>',
|
||||||
|
'<meta name="description" content="__SEO_DESCRIPTION__">',
|
||||||
|
'<link rel="canonical" href="__SEO_CANONICAL__">',
|
||||||
|
'<meta property="og:image" content="__SEO_IMAGE__">',
|
||||||
|
'<script nonce="__CSP_NONCE__">__SEO_JSON_LD__</script>'
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
test('renders a meme permalink with meme-specific share metadata', () => {
|
||||||
|
const html = renderIndex({
|
||||||
|
template,
|
||||||
|
baseUrl: 'https://memes.example',
|
||||||
|
nonce: 'test-nonce',
|
||||||
|
approvedCount: 1,
|
||||||
|
meme
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.match(html, /<title>Meme 0xAAAA\.\.\.AAAA \| The Meme Protocol<\/title>/);
|
||||||
|
assert.match(html, new RegExp(`canonical" href="https://memes\\.example/meme/${id}"`));
|
||||||
|
assert.match(html, new RegExp(`og:image" content="https://memes\\.example/media/${id}"`));
|
||||||
|
assert.match(html, /"@type":"ImageObject"/);
|
||||||
|
assert.match(html, /nonce="test-nonce"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('discovery feeds point readers to viewer permalinks', () => {
|
||||||
|
const feed = feedJson('https://memes.example', [meme]);
|
||||||
|
const sitemap = sitemapXml('https://memes.example', [meme]);
|
||||||
|
|
||||||
|
assert.equal(feed.items[0].url, `https://memes.example/meme/${id}`);
|
||||||
|
assert.equal(feed.items[0].image, `https://memes.example/media/${id}`);
|
||||||
|
assert.match(sitemap, new RegExp(`https://memes\\.example/meme/${id}`));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user