feat: release meme protocol v1.1.0

Add one-click meme copying, complete viewer actions, fixed-footer pagination, protocol lore, and the Homo Reseticus download chooser.

Install dedicated social and favicon assets, expand local demo data, and synchronize the changelog, documentation, package, and OpenAPI release metadata.
This commit is contained in:
2026-08-14 18:54:37 -03:00
parent 0bf215555a
commit 48f05bcf8a
24 changed files with 702 additions and 81 deletions
+24
View File
@@ -0,0 +1,24 @@
# Changelog
All notable changes to The Meme Protocol are documented here. Versions follow Semantic Versioning.
## [1.1.0] - 2026-08-14
### Added
- One-click image copying from feed cards and the full-image viewer.
- Copy, download, share, view-count, and download-count controls in the full-image viewer.
- `DOWNLOAD PET` and `READ LORE` protocol menu actions.
- Shareable `/lore` viewer backed by a downloadable plain-text lore file.
- Site-styled pet download chooser offering the original and V2 Homo Reseticus ZIP packages, with a Petdex availability link.
- Eighteen generated demo memes, including automatic top-up of older demo-only stores, so local scrolling exercises pagination.
- A 300ms minimum spinner display for subsequent pages, preventing imperceptible flashes on fast local connections.
- Dedicated `1200x630` social preview metadata for homepage and lore shares, replacing the source repository logo.
- A complete, cache-versioned favicon suite for browser tabs, bookmarks, Apple Touch icons, and installed web apps.
### Changed
- Fixed the source footer to the viewport bottom and reserved enough scroll space to keep the final images and feed loader unobscured.
- Expanded meme IDs in the full-image viewer while retaining compact IDs on feed cards.
- Renamed the user-facing `SUBMIT` / `SUBMIT_MEME` language to `ADD MEME` / `ADD_MEME`.
- Updated package and OpenAPI versions from `1.0.0` to `1.1.0` for this backwards-compatible feature release.
+10 -2
View File
@@ -2,6 +2,14 @@
Small self-hosted meme gallery matching the `stitch_the_meme_protocol` desktop mockup direction.
Current release: `1.1.0`.
The browser feed supports one-click image copy, downloads, shareable meme links, full-image viewing with live view/download counts, a fixed source footer, a two-build Homo Reseticus pet download chooser, and shareable lore at `/lore`.
Homepage and lore social shares use the versioned `1200x630` asset at `/assets/social-preview-v1.png`. Individual meme permalinks continue to use their own meme image.
Browser tabs, bookmarks, Apple home-screen links, and installed web apps use the supplied favicon suite under `/assets/favicon/`; the legacy `/favicon.ico` URL remains available for automatic browser requests.
## Run locally
```sh
@@ -16,7 +24,7 @@ The server listens on `http://localhost:8080` by default.
- `HOST`: bind address, default `0.0.0.0`
- `DATA_DIR`: disk storage root, default `./data`
- `SITE_URL`: public canonical site URL used for SEO metadata, sitemaps, feeds, `llms.txt`, OpenAPI servers, and API skill instructions. Production should use `https://bitsforfree.com`.
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes. When enabled, empty and legacy demo-only stores are filled to 18 samples so local pagination can be tested.
- `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs.
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
- `OPENAI_MODERATION_MODEL`: moderation vision model, default `gpt-4o-mini`
@@ -56,7 +64,7 @@ Set `SITE_URL` in production so canonical and API discovery URLs use the public
- `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.
- `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`.
- `POST /api/memes`: adds one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`.
API uploads use the exact same path as the browser form: persistent IP upload quotas, file size checks, image dimension and pixel checks, metadata-stripping WebP normalization, AI moderation, and admin-review queueing. A published upload returns `201`; a queued upload returns `202` with `moderationReason`; moderation rejection returns `422` with `moderationReason`.
+4 -4
View File
@@ -1,6 +1,6 @@
# The Meme Protocol API Skill
Use this skill when an agent needs to read approved memes from The Meme Protocol or submit a meme image to the public upload API.
Use this skill when an agent needs to read approved memes from The Meme Protocol or add a meme image through the public upload API.
## Discovery
@@ -9,7 +9,7 @@ Use this skill when an agent needs to read approved memes from The Meme Protocol
- Public meme list: `GET __API_BASE_URL__/api/memes`
- Public meme metadata: `GET __API_BASE_URL__/api/memes/{id}`
- Public meme image: `GET __API_BASE_URL__/media/{id}`
- Submit meme: `POST __API_BASE_URL__/api/memes`
- Add meme: `POST __API_BASE_URL__/api/memes`
## Read Memes
@@ -21,9 +21,9 @@ Use each meme's `url` field to fetch the normalized WebP image. Public image URL
Read endpoints are rate-limited per IP. Respect `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` headers. Prefer small page sizes and back off on `429`.
## Submit Memes
## Add Memes
Submit with `POST __API_BASE_URL__/api/memes` as `multipart/form-data`. The image file field must be named `meme`.
Add a meme with `POST __API_BASE_URL__/api/memes` as `multipart/form-data`. The image file field must be named `meme`.
Example:
+8 -8
View File
@@ -2,8 +2,8 @@
"openapi": "3.1.0",
"info": {
"title": "The Meme Protocol API",
"version": "1.0.0",
"description": "Public API for listing approved memes and submitting PNG/JPEG meme uploads. Uploads follow the same moderation, image validation, normalization, and IP quota rules as the browser form."
"version": "1.1.0",
"description": "Public API for listing approved memes and adding PNG/JPEG meme uploads. Uploads follow the same moderation, image validation, normalization, and IP quota rules as the browser form."
},
"servers": [
{
@@ -13,7 +13,7 @@
"tags": [
{
"name": "memes",
"description": "Approved meme discovery and submission"
"description": "Approved meme discovery and addition"
},
{
"name": "discovery",
@@ -87,8 +87,8 @@
"tags": [
"memes"
],
"summary": "Submit a meme",
"description": "Submits one PNG or JPEG image using the same path as the browser upload form. The request must be multipart/form-data with a file field named meme. The server enforces the same size, dimension, pixel, moderation, normalization, and per-IP upload limits as human submissions.",
"summary": "Add a meme",
"description": "Adds one PNG or JPEG image using the same path as the browser upload form. The request must be multipart/form-data with a file field named meme. The server enforces the same size, dimension, pixel, moderation, normalization, and per-IP upload limits as browser additions.",
"requestBody": {
"required": true,
"content": {
@@ -120,7 +120,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemeSubmitResponse"
"$ref": "#/components/schemas/MemeAddResponse"
}
}
}
@@ -130,7 +130,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemeSubmitResponse"
"$ref": "#/components/schemas/MemeAddResponse"
}
}
}
@@ -438,7 +438,7 @@
}
}
},
"MemeSubmitResponse": {
"MemeAddResponse": {
"type": "object",
"required": [
"meme",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meme-protocol",
"version": "1.0.0",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meme-protocol",
"version": "1.0.0",
"version": "1.1.0",
"dependencies": {
"sharp": "^0.34.5"
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "meme-protocol",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"description": "Self-hosted meme gallery for The Meme Protocol.",
"type": "module",
+216 -25
View File
@@ -5,6 +5,7 @@ const VIEW_DWELL_MS = 1000;
const VIEW_THRESHOLD = 0.9;
const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000;
const VIEW_DEDUPE_PREFIX = 'meme-viewed:';
const NEXT_PAGE_LOADER_MIN_MS = 300;
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
const grid = document.querySelector('#meme-grid');
@@ -15,17 +16,28 @@ const uploadForm = document.querySelector('#upload-form');
const fileInput = document.querySelector('#meme-input');
const fileLabel = document.querySelector('#file-label');
const formStatus = document.querySelector('#form-status');
const submitUpload = document.querySelector('#submit-upload');
const addMemeButton = document.querySelector('#add-meme-button');
const lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image');
const lightboxCopy = document.querySelector('#lightbox-copy');
const lightboxDownload = document.querySelector('#lightbox-download');
const lightboxShare = document.querySelector('#lightbox-share');
const lightboxId = document.querySelector('#lightbox-id');
const lightboxViewCount = document.querySelector('#lightbox-view-count');
const lightboxDownloadCount = document.querySelector('#lightbox-download-count');
const petModal = document.querySelector('#pet-modal');
const loreModal = document.querySelector('#lore-modal');
const loreText = document.querySelector('#lore-text');
const loreCopy = document.querySelector('#lore-copy');
const loreShare = document.querySelector('#lore-share');
const shareModal = document.querySelector('#share-modal');
const shareTitle = document.querySelector('#share-title');
const shareLinkLabel = document.querySelector('#share-link-label');
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 actionStatus = document.querySelector('#action-status');
let currentPage = 0;
let totalPages = 1;
@@ -33,6 +45,8 @@ let isLoading = false;
let latestValidationRun = 0;
let activeShareTrigger = null;
let shareStatusTimer = null;
let actionStatusTimer = null;
let loreContent = '';
const viewedThisSession = new Set();
const pendingViewTimers = new Map();
const copiedFeedbackTimers = new WeakMap();
@@ -45,10 +59,22 @@ document.querySelector('#close-upload').addEventListener('click', closeUpload);
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
document.querySelector('#open-pet').addEventListener('click', () => petModal.showModal());
document.querySelector('#close-pet').addEventListener('click', () => petModal.close());
document.querySelector('#open-lore').addEventListener('click', openLoreDialog);
document.querySelector('#close-lore').addEventListener('click', () => loreModal.close());
copyShareLinkButton.addEventListener('click', copyActiveShareLink);
lightboxShare.addEventListener('click', async () => {
await openShareDialog(lightboxShare.dataset.shareId, lightboxShare);
await openMemeShareDialog(lightboxShare.dataset.shareId, lightboxShare);
});
lightboxCopy.addEventListener('click', () => copyImage(lightboxCopy.dataset.imageUrl, lightboxCopy));
loreCopy.addEventListener('click', copyLore);
loreShare.addEventListener('click', () => openShareDialog({
path: '/lore',
title: 'SHARE_LORE',
label: 'LORE_LINK',
triggerButton: loreShare
}));
fileInput.addEventListener('change', async () => {
const validationRun = ++latestValidationRun;
@@ -70,7 +96,7 @@ uploadForm.addEventListener('submit', async (event) => {
return;
}
submitUpload.disabled = true;
addMemeButton.disabled = true;
formStatus.textContent = 'UPLOADING...';
try {
@@ -90,14 +116,20 @@ uploadForm.addEventListener('submit', async (event) => {
} catch (error) {
formStatus.textContent = error.message.toUpperCase();
} finally {
submitUpload.disabled = false;
addMemeButton.disabled = false;
}
});
grid.addEventListener('click', async (event) => {
const copyButton = event.target.closest('[data-copy-image-url]');
if (copyButton) {
await copyImage(copyButton.dataset.copyImageUrl, copyButton);
return;
}
const shareButton = event.target.closest('[data-share-id]');
if (shareButton) {
await openShareDialog(shareButton.dataset.shareId, shareButton);
await openMemeShareDialog(shareButton.dataset.shareId, shareButton);
return;
}
@@ -108,7 +140,9 @@ grid.addEventListener('click', async (event) => {
id: viewButton.dataset.viewId,
url: viewButton.dataset.viewUrl,
downloadUrl: viewButton.dataset.downloadUrl,
alt: card.querySelector('img').alt
alt: card.querySelector('img').alt,
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
});
});
@@ -128,7 +162,7 @@ window.addEventListener('scroll', updateScrollIndicator, { passive: true });
window.addEventListener('resize', updateScrollIndicator);
refreshStatus();
window.setInterval(refreshStatus, 5000);
await openSharedMemeFromUrl();
await openSharedContentFromUrl();
await loadMemes(1, { reset: true });
observer.observe(feedLoader);
@@ -140,6 +174,7 @@ async function loadNextPage() {
async function loadMemes(page, options = {}) {
if (isLoading) return;
isLoading = true;
const loaderStartedAt = performance.now();
setLoader('FETCHING_NEXT_BLOCK...', true);
try {
@@ -150,6 +185,10 @@ async function loadMemes(page, options = {}) {
currentPage = payload.page;
totalPages = payload.totalPages;
renderMemes(payload.memes, { append: !options.reset });
if (page > 1) {
const remainingLoaderTime = NEXT_PAGE_LOADER_MIN_MS - (performance.now() - loaderStartedAt);
if (remainingLoaderTime > 0) await delay(remainingLoaderTime);
}
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
updateScrollIndicator();
} catch {
@@ -161,6 +200,10 @@ async function loadMemes(page, options = {}) {
}
}
function delay(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function renderMemes(memes, options = {}) {
if (!options.append && memes.length === 0) {
unobserveViewedImages();
@@ -185,13 +228,14 @@ function renderMemes(memes, options = {}) {
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span>${formatCount(meme.viewCount)}</span>
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span>${formatCount(meme.downloadCount)}</span>
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>${formatCount(meme.viewCount)}</span></span>
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>${formatCount(meme.downloadCount)}</span></span>
</div>
<div class="action-group">
<button class="text-action icon-only-action" type="button" data-copy-image-url="${meme.url}" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<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 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>
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" data-view-count="${meme.viewCount}" data-download-count="${meme.downloadCount}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
</div>
</div>
`;
@@ -208,7 +252,12 @@ function renderMemes(memes, options = {}) {
observeViewedImages(cards);
}
async function openSharedMemeFromUrl() {
async function openSharedContentFromUrl() {
if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') {
await openLoreDialog();
return;
}
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (!match) return;
@@ -225,20 +274,39 @@ async function openSharedMemeFromUrl() {
}
}
async function openLightbox({ id, url, downloadUrl, alt }) {
async function openLightbox({ id, url, downloadUrl, alt, viewCount = 0, downloadCount = 0 }) {
lightboxImage.src = url;
lightboxImage.alt = alt;
lightboxCopy.dataset.imageUrl = url;
lightboxDownload.href = downloadUrl;
lightboxShare.dataset.shareId = id;
lightboxId.textContent = `ID: ${shortId(id)}`;
lightboxViewCount.dataset.counterId = id;
lightboxViewCount.dataset.counterKind = 'view';
lightboxViewCount.querySelector('[data-count]').textContent = formatCount(viewCount);
lightboxDownloadCount.dataset.counterId = id;
lightboxDownloadCount.dataset.counterKind = 'download';
lightboxDownloadCount.querySelector('[data-count]').textContent = formatCount(downloadCount);
lightboxId.textContent = `ID: 0x${id.toUpperCase()}`;
setActionStatus('', lightboxCopy);
if (!lightbox.open) lightbox.showModal();
await recordViewOnce(id);
}
async function openShareDialog(id, triggerButton = null) {
async function openMemeShareDialog(id, triggerButton = null) {
if (!isMemeId(id)) return;
return openShareDialog({
path: `/meme/${id}`,
title: 'SHARE_MEME',
label: 'MEME_LINK',
triggerButton
});
}
async function openShareDialog({ path, title, label, triggerButton = null }) {
activeShareTrigger = triggerButton;
shareLink.value = new URL(`/meme/${id}`, window.location.origin).href;
shareTitle.textContent = title;
shareLinkLabel.textContent = label;
shareLink.value = new URL(path, window.location.origin).href;
window.clearTimeout(shareStatusTimer);
shareStatusTimer = null;
shareStatus.textContent = '';
@@ -252,8 +320,8 @@ async function copyActiveShareLink() {
try {
await copyText(shareLink.value);
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
showCopiedFeedback(copyShareLinkButton);
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger);
showCopiedFeedback(copyShareLinkButton, 'Share link copied');
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied');
window.clearTimeout(shareStatusTimer);
shareStatusTimer = window.setTimeout(() => {
shareStatus.textContent = '';
@@ -268,7 +336,104 @@ async function copyActiveShareLink() {
}
}
function showCopiedFeedback(button) {
async function openLoreDialog() {
setActionStatus('', loreCopy);
if (!loreModal.open) loreModal.showModal();
if (loreContent) return;
try {
const response = await fetch('/assets/meme-protocol-lore.txt');
if (!response.ok) throw new Error('Lore unavailable.');
loreContent = await response.text();
loreText.textContent = loreContent;
} catch {
loreText.textContent = 'LORE_TRANSMISSION_UNAVAILABLE';
setActionStatus('LORE_TRANSMISSION_UNAVAILABLE', loreCopy);
}
}
async function copyLore() {
if (!loreContent) await openLoreDialog();
if (!loreContent) return;
loreCopy.disabled = true;
try {
await copyText(loreContent);
showCopiedFeedback(loreCopy, 'Lore copied');
setActionStatus('LORE_COPIED_TO_CLIPBOARD', loreCopy);
} catch {
setActionStatus('LORE_COPY_UNAVAILABLE — USE_DOWNLOAD', loreCopy);
} finally {
loreCopy.disabled = false;
}
}
async function copyImage(url, button) {
if (!url || !button) return;
button.disabled = true;
setActionStatus('', button);
try {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
throw new Error('Image clipboard unavailable.');
}
const png = fetch(url)
.then((response) => {
if (!response.ok) throw new Error('Image fetch failed.');
return response.blob();
})
.then(convertImageBlobToPng);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
showCopiedFeedback(button, 'Meme image copied');
setActionStatus('MEME_IMAGE_COPIED_TO_CLIPBOARD', button);
} catch {
setActionStatus('IMAGE_COPY_UNAVAILABLE — USE_DOWNLOAD', button);
} finally {
button.disabled = false;
}
}
async function convertImageBlobToPng(blob) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas unavailable.');
let image;
let cleanup = () => {};
if ('createImageBitmap' in window) {
image = await createImageBitmap(blob);
cleanup = () => image.close();
} else {
const objectUrl = URL.createObjectURL(blob);
image = await loadImage(objectUrl);
cleanup = () => URL.revokeObjectURL(objectUrl);
}
try {
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
return await new Promise((resolve, reject) => {
canvas.toBlob((png) => {
if (png) resolve(png);
else reject(new Error('PNG conversion failed.'));
}, 'image/png');
});
} finally {
cleanup();
}
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Image decode failed.'));
image.src = url;
});
}
function showCopiedFeedback(button, copiedLabel = 'Copied') {
const icon = button.querySelector('.material-symbols-outlined');
const label = button.querySelector('[data-copy-label]');
if (!icon) return;
@@ -280,8 +445,8 @@ function showCopiedFeedback(button) {
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');
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', copiedLabel);
if (button.dataset.defaultTitle) button.setAttribute('title', copiedLabel);
button.classList.add('is-copied');
window.clearTimeout(copiedFeedbackTimers.get(button));
@@ -308,9 +473,29 @@ async function copyText(value) {
return;
}
shareLink.focus();
shareLink.select();
if (!document.execCommand('copy')) throw new Error('Clipboard unavailable.');
const fallback = document.createElement('textarea');
fallback.value = value;
fallback.setAttribute('readonly', '');
fallback.className = 'clipboard-fallback';
document.body.append(fallback);
fallback.select();
const copied = document.execCommand('copy');
fallback.remove();
if (!copied) throw new Error('Clipboard unavailable.');
}
function setActionStatus(message, triggerButton = null) {
window.clearTimeout(actionStatusTimer);
for (const status of document.querySelectorAll('[data-action-status], #action-status')) {
status.textContent = '';
}
const target = triggerButton?.closest('dialog')?.querySelector('[data-action-status]') || actionStatus;
target.textContent = message;
if (!message) return;
actionStatusTimer = window.setTimeout(() => {
target.textContent = '';
actionStatusTimer = null;
}, 3200);
}
async function validateClientFile(file) {
@@ -464,13 +649,19 @@ function connectLiveCounters() {
function updateCounters(meme) {
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
const count = element.querySelector('[data-count]');
if (!count) continue;
if (element.dataset.counterKind === 'view') {
element.lastChild.textContent = formatCount(meme.viewCount);
count.textContent = formatCount(meme.viewCount);
}
if (element.dataset.counterKind === 'download') {
element.lastChild.textContent = formatCount(meme.downloadCount);
count.textContent = formatCount(meme.downloadCount);
}
}
for (const button of document.querySelectorAll(`[data-view-id="${meme.id}"]`)) {
button.dataset.viewCount = String(meme.viewCount);
button.dataset.downloadCount = String(meme.downloadCount);
}
}
function setLoader(label, spinning) {
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

+9
View File
@@ -0,0 +1,9 @@
THE MEME PROTOCOL // LORE PLACEHOLDER
The stream remembers what the timeline forgets.
Every meme enters as a signal, sheds its metadata, and waits for consensus.
The worthy survive as immutable blocks of shared context. The rest dissolve
back into the noise.
This is placeholder lore. A future transmission will replace it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+214 -11
View File
@@ -18,6 +18,7 @@
--error: #ffb4ab;
--gutter: 16px;
--desktop-margin: 32px;
--footer-height: 44px;
}
* {
@@ -29,6 +30,10 @@ body {
min-height: 100%;
}
html {
scroll-padding-bottom: calc(var(--footer-height) + 24px);
}
body {
margin: 0;
background: var(--void);
@@ -148,7 +153,7 @@ a {
.shell {
width: min(100%, 1440px);
margin: 0 auto;
padding: 96px var(--desktop-margin) 48px;
padding: 96px var(--desktop-margin) calc(var(--footer-height) + 16px + env(safe-area-inset-bottom));
}
.status-line {
@@ -162,6 +167,33 @@ a {
border-bottom: 1px solid var(--outline-variant);
}
.protocol-menu {
display: flex;
align-items: center;
gap: 16px;
}
.protocol-menu a,
.protocol-menu button {
appearance: none;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
text-decoration: none;
}
.protocol-menu a:hover,
.protocol-menu a:focus-visible,
.protocol-menu button:hover,
.protocol-menu button:focus-visible {
color: var(--primary-dim);
text-decoration: underline;
text-underline-offset: 3px;
}
.live {
display: inline-flex;
align-items: center;
@@ -379,14 +411,19 @@ a {
color: var(--primary);
}
.text-action:disabled {
cursor: wait;
opacity: 0.65;
}
.feed-loader {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
min-height: 104px;
margin-top: 24px;
gap: 12px;
min-height: 64px;
margin-top: 16px;
color: var(--outline);
font-size: 10px;
font-weight: 600;
@@ -395,24 +432,31 @@ a {
}
.loader-ring {
display: none;
width: 32px;
height: 32px;
border: 2px solid transparent;
border-top-color: var(--primary);
border-radius: 9999px;
opacity: 0;
}
.feed-loader.is-spinning .loader-ring {
display: block;
animation: spin 850ms linear infinite;
opacity: 1;
}
.repo-footer {
position: fixed;
z-index: 20;
inset: auto 0 0;
display: flex;
min-height: calc(var(--footer-height) + env(safe-area-inset-bottom));
align-items: center;
justify-content: center;
margin-top: -8px;
padding-bottom: 8px;
padding: 0 16px env(safe-area-inset-bottom);
border-top: 1px solid var(--outline-variant);
background: var(--void);
backdrop-filter: blur(12px);
}
.repo-footer a {
@@ -535,6 +579,10 @@ dialog::backdrop {
width: min(640px, calc(100vw - 32px));
}
.pet-panel {
width: min(580px, calc(100vw - 32px));
}
.modal-header,
.lightbox-toolbar {
display: flex;
@@ -546,6 +594,15 @@ dialog::backdrop {
background: var(--surface);
}
.lightbox-toolbar > span {
min-width: 0;
overflow-wrap: anywhere;
}
.lightbox-toolbar .icon-action {
flex: 0 0 auto;
}
.modal-header h2 {
margin: 0;
font-family: Geist, Inter, system-ui, sans-serif;
@@ -642,6 +699,88 @@ dialog::backdrop {
color: var(--primary-dim);
}
.pet-downloads {
display: grid;
gap: 12px;
padding: 16px;
}
.pet-prompt {
margin: 0 0 4px;
color: var(--outline);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.pet-footnote {
margin: 4px 0 0;
color: var(--outline);
font-size: 9px;
line-height: 1.5;
}
.pet-footnote a {
color: var(--muted);
text-underline-offset: 2px;
}
.pet-footnote a:hover,
.pet-footnote a:focus-visible {
color: var(--primary-dim);
}
.pet-download {
display: flex;
min-width: 0;
min-height: 72px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--outline-variant);
background: var(--surface-lowest);
color: var(--muted);
text-decoration: none;
}
.pet-download:hover,
.pet-download:focus-visible {
border-color: var(--primary-dim);
background: var(--surface-high);
color: var(--primary-dim);
}
.pet-download > span:first-child {
display: grid;
min-width: 0;
gap: 6px;
}
.pet-download strong,
.pet-download small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pet-download strong {
font-size: 12px;
letter-spacing: 0.1em;
}
.pet-download small {
color: var(--outline);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
}
.pet-download > .material-symbols-outlined {
flex: 0 0 auto;
}
.modal-actions {
display: flex;
justify-content: flex-end;
@@ -697,11 +836,16 @@ dialog::backdrop {
box-shadow: 0 0 24px rgb(0 255 65 / 14%);
}
.lightbox[open] {
display: flex;
flex-direction: column;
}
.lightbox img {
display: block;
width: auto;
max-width: 100%;
max-height: calc(100vh - 144px);
max-height: calc(100vh - 176px);
margin: 0 auto;
object-fit: contain;
background: #000;
@@ -709,9 +853,68 @@ dialog::backdrop {
.lightbox-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin: 12px 12px 4px;
}
.lorebox {
width: min(760px, calc(100vw - 32px));
}
.lore-text {
min-height: 260px;
max-height: calc(100vh - 176px);
margin: 0;
overflow: auto;
padding: 24px;
background: #080808;
color: var(--text);
font: inherit;
font-size: 13px;
line-height: 1.7;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.lore-actions {
justify-content: flex-end;
gap: 8px;
margin: 12px;
}
.viewer-status {
min-height: 16px;
margin: 0 12px 8px;
color: var(--primary-dim);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.action-toast {
position: fixed;
z-index: 19;
right: 16px;
bottom: calc(var(--footer-height) + 12px + env(safe-area-inset-bottom));
max-width: calc(100vw - 32px);
margin: 0;
padding: 6px 8px;
background: var(--surface);
color: var(--primary-dim);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.action-toast:empty {
display: none;
}
.clipboard-fallback {
position: fixed;
inset: auto auto 0 -9999px;
}
.empty-state {
+81 -14
View File
@@ -9,7 +9,7 @@
<meta name="application-name" content="__SEO_TITLE__">
<meta name="theme-color" content="#00ff41">
<link rel="canonical" href="__SEO_CANONICAL__">
<link rel="manifest" href="/site.webmanifest">
<link rel="manifest" href="/site.webmanifest?v=20260814">
<link rel="service-desc" type="application/openapi+json" href="/openapi.json">
<link rel="help" type="text/markdown" href="/meme-api.skill.md">
<meta property="og:type" content="website">
@@ -18,10 +18,20 @@
<meta property="og:description" content="__SEO_DESCRIPTION__">
<meta property="og:url" content="__SEO_CANONICAL__">
<meta property="og:image" content="__SEO_IMAGE__">
<meta property="og:image:type" content="__SEO_IMAGE_TYPE__">
<meta property="og:image:width" content="__SEO_IMAGE_WIDTH__">
<meta property="og:image:height" content="__SEO_IMAGE_HEIGHT__">
<meta property="og:image:alt" content="__SEO_IMAGE_ALT__">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="__SEO_TITLE__">
<meta name="twitter:description" content="__SEO_DESCRIPTION__">
<meta name="twitter:image" content="__SEO_IMAGE__">
<meta name="twitter:image:alt" content="__SEO_IMAGE_ALT__">
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg?v=20260814">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png?v=20260814">
<link rel="shortcut icon" href="/favicon.ico?v=20260814">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png?v=20260814">
<meta name="apple-mobile-web-app-title" content="Meme Protocol">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
@@ -32,12 +42,15 @@
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL</div>
<button class="primary-action" id="open-upload" type="button">SUBMIT</button>
<button class="primary-action" id="open-upload" type="button">ADD MEME</button>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<span>PROTOCOL_STREAM_V.1.04</span>
<nav class="protocol-menu" aria-label="Protocol menu">
<button id="open-pet" type="button">DOWNLOAD PET</button>
<button id="open-lore" type="button">READ LORE</button>
</nav>
<span class="live"><span class="pulse"></span>LIVE_FEED</span>
</div>
@@ -56,14 +69,15 @@
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
</div>
<footer class="repo-footer">
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
<span>SOURCE</span>
</a>
</footer>
</main>
<footer class="repo-footer">
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
<span>SOURCE</span>
</a>
</footer>
<aside class="side-terminal" aria-live="polite">
<div>STATUS: <span data-stat="status">SYNCING</span></div>
<div>LATENCY: <span data-stat="latency">--MS</span></div>
@@ -79,7 +93,7 @@
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
<form class="modal-panel" id="upload-form" method="dialog">
<div class="modal-header">
<h2 id="upload-title">SUBMIT_MEME</h2>
<h2 id="upload-title">ADD_MEME</h2>
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
</div>
<label class="file-drop" for="meme-input">
@@ -96,7 +110,7 @@
</div>
<div class="modal-actions">
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
<button class="primary-action" id="submit-upload" type="submit">UPLOAD</button>
<button class="primary-action" id="add-meme-button" type="submit">ADD MEME</button>
</div>
<p class="form-status" id="form-status" role="status"></p>
</form>
@@ -109,9 +123,60 @@
</div>
<img id="lightbox-image" alt="">
<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 class="stats">
<span class="card-stat" id="lightbox-view-count"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>0</span></span>
<span class="card-stat" id="lightbox-download-count"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>0</span></span>
</div>
<div class="action-group">
<button class="text-action icon-only-action" id="lightbox-copy" type="button" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<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>
</div>
<p class="viewer-status" data-action-status role="status" aria-live="polite"></p>
</dialog>
<dialog class="modal" id="pet-modal" aria-labelledby="pet-title">
<div class="modal-panel pet-panel">
<div class="modal-header">
<h2 id="pet-title">DOWNLOAD_PET</h2>
<button class="icon-action" id="close-pet" type="button" aria-label="Close">X</button>
</div>
<div class="pet-downloads">
<p class="pet-prompt">SELECT_PET_BUILD</p>
<a class="pet-download" href="/assets/downloads/homo-reseticus.zip" download="homo-reseticus.zip">
<span>
<strong>HOMO_RESETICUS</strong>
<small>ORIGINAL // ZIP // 805 KB</small>
</span>
<span class="material-symbols-outlined" aria-hidden="true">download</span>
</a>
<a class="pet-download" href="/assets/downloads/homo-reseticus-v2.zip" download="homo-reseticus-v2.zip">
<span>
<strong>HOMO_RESETICUS_V2</strong>
<small>VERSION_2 // ZIP // 731 KB</small>
</span>
<span class="material-symbols-outlined" aria-hidden="true">download</span>
</a>
<p class="pet-footnote">This pet can also be downloaded from <a href="https://petdex.dev/u/homo-reseticus" rel="noopener noreferrer" target="_blank">petdex.dev/u/homo-reseticus</a>.</p>
</div>
</div>
</dialog>
<dialog class="lightbox lorebox" id="lore-modal" aria-labelledby="lore-title">
<div class="lightbox-toolbar">
<span id="lore-title">MEME_PROTOCOL_LORE.TXT</span>
<button class="icon-action" id="close-lore" type="button" aria-label="Close">X</button>
</div>
<pre class="lore-text" id="lore-text">FETCHING_LORE...</pre>
<div class="lightbox-actions lore-actions">
<div class="action-group">
<button class="text-action icon-only-action" id="lore-copy" type="button" aria-label="Copy lore" title="Copy lore"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" href="/assets/meme-protocol-lore.txt" download="meme-protocol-lore.txt" aria-label="Download lore" title="Download lore"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" id="lore-share" type="button" aria-label="Share lore" title="Share lore"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
</div>
</div>
<p class="viewer-status" data-action-status role="status" aria-live="polite"></p>
</dialog>
<dialog class="modal" id="share-modal" aria-labelledby="share-title">
@@ -121,7 +186,7 @@
<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>
<label for="share-link" id="share-link-label">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">
@@ -133,5 +198,7 @@
</div>
</div>
</dialog>
<p class="action-toast" id="action-status" role="status" aria-live="polite"></p>
</body>
</html>
+18 -2
View File
@@ -75,6 +75,18 @@ const server = http.createServer(async (req, res) => {
}));
}
if (req.method === 'GET' && (url.pathname === '/lore' || url.pathname === '/lore/')) {
const nonce = crypto.randomBytes(16).toString('base64');
withSecurityHeaders(res, { scriptNonce: nonce });
return sendHtml(res, 200, renderIndex({
template: indexTemplate,
baseUrl,
nonce,
approvedCount: store.count('approved'),
lore: true
}));
}
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (req.method === 'GET' && memePageMatch) {
const meme = store.get(memePageMatch[1]);
@@ -112,6 +124,10 @@ const server = http.createServer(async (req, res) => {
return sendJson(res, 200, manifest(baseUrl));
}
if (req.method === 'GET' && url.pathname === '/favicon.ico') {
return sendFile(res, './public/assets/favicon/favicon.ico');
}
if (req.method === 'GET' && url.pathname === '/feed.json') {
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
}
@@ -203,7 +219,7 @@ const server = http.createServer(async (req, res) => {
if (req.method === 'POST' && url.pathname === '/api/memes') {
noIndex(res);
const { meme, quota } = await submitMeme(req);
const { meme, quota } = await addMeme(req);
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
meme,
quota,
@@ -361,7 +377,7 @@ function broadcastMetric(meme) {
}
}
async function submitMeme(req) {
async function addMeme(req) {
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
if (!Number.isFinite(contentLength) || contentLength <= 0) {
const error = new Error('Missing upload size.');
+3
View File
@@ -6,10 +6,13 @@ const TYPES = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.svg', 'image/svg+xml'],
['.zip', 'application/zip'],
['.ico', 'image/x-icon']
]);
+11 -3
View File
@@ -2,12 +2,20 @@ import { validateImage } from './image.js';
import { normalizeToWebp } from './normalize.js';
import { encodePng } from './png.js';
const DEMO_COUNT = 12;
const DEMO_COUNT = 18;
export async function seedDemoMemes(store) {
if (store.count() > 0) return;
const total = store.count();
const existing = store.list({ page: 1, pageSize: Math.max(DEMO_COUNT, total) }).memes;
const isDemoOnlyStore = existing.length === total && existing.every((meme) => meme.demo);
if (total > 0 && !isDemoOnlyStore) return;
const existingNames = new Set(existing.map((meme) => meme.originalName));
for (let index = 0; index < DEMO_COUNT; index += 1) {
const originalName = `protocol-sample-${String(index + 1).padStart(2, '0')}.png`;
if (existingNames.has(originalName)) continue;
const width = 960;
const height = 960;
const buffer = encodePng(width, height, pixelFactory(index));
@@ -22,7 +30,7 @@ export async function seedDemoMemes(store) {
await store.save({
buffer: normalized.buffer,
image: normalized.image,
originalName: `protocol-sample-${String(index + 1).padStart(2, '0')}.png`,
originalName,
originalMime: image.mime,
createdAt,
demo: true
+48 -8
View File
@@ -1,6 +1,9 @@
const SITE_NAME = 'The Meme Protocol';
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
const SOCIAL_IMAGE_PATH = '/assets/social-preview-v1.png';
const FAVICON_PATH = '/assets/favicon';
const FAVICON_VERSION = '20260814';
export function publicBaseUrl(req) {
if (process.env.SITE_URL) return cleanBase(process.env.SITE_URL);
@@ -13,14 +16,20 @@ export function publicBaseUrl(req) {
return cleanBase(`${proto}://${host || 'localhost:8080'}`);
}
export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null }) {
export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null, lore = false }) {
const home = `${baseUrl}/`;
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : home;
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}/assets/yoonect-logo.png`;
const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : SITE_NAME;
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : lore ? `${baseUrl}/lore` : home;
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}${SOCIAL_IMAGE_PATH}`;
const imageType = meme ? meme.mime : 'image/png';
const imageWidth = meme ? meme.width : 1200;
const imageHeight = meme ? meme.height : 630;
const imageAlt = meme ? `Meme ${shortId(meme.id)}` : `${SITE_NAME} — memes bring us together`;
const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : lore ? `Lore | ${SITE_NAME}` : SITE_NAME;
const description = meme
? `View and share meme ${shortId(meme.id)} on ${SITE_NAME}.`
: SITE_DESCRIPTION;
: lore
? `Read, copy, download, and share the lore of ${SITE_NAME}.`
: SITE_DESCRIPTION;
const pageMetadata = meme
? {
'@type': 'ImageObject',
@@ -33,6 +42,20 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu
height: meme.height,
isPartOf: { '@id': `${home}#website` }
}
: lore
? {
'@type': 'CreativeWork',
'@id': `${canonical}#lore`,
name: `${SITE_NAME} Lore`,
url: canonical,
description,
encoding: {
'@type': 'MediaObject',
contentUrl: `${baseUrl}/assets/meme-protocol-lore.txt`,
encodingFormat: 'text/plain'
},
isPartOf: { '@id': `${home}#website` }
}
: {
'@type': 'CollectionPage',
'@id': `${canonical}#collection`,
@@ -72,6 +95,10 @@ export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = nu
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(description))
.replaceAll('__SEO_CANONICAL__', canonical)
.replaceAll('__SEO_IMAGE__', image)
.replaceAll('__SEO_IMAGE_TYPE__', imageType)
.replaceAll('__SEO_IMAGE_WIDTH__', String(imageWidth))
.replaceAll('__SEO_IMAGE_HEIGHT__', String(imageHeight))
.replaceAll('__SEO_IMAGE_ALT__', escapeHtml(imageAlt))
.replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd)))
.replaceAll('__CSP_NONCE__', nonce);
}
@@ -102,6 +129,7 @@ export function llmsTxt(baseUrl) {
`- API skill: ${baseUrl}/meme-api.skill.md`,
`- JSON feed: ${baseUrl}/feed.json`,
`- Sitemap: ${baseUrl}/sitemap.xml`,
`- Lore: ${baseUrl}/lore`,
`- Source: ${REPO_URL}`,
'',
'Crawler guidance:',
@@ -125,10 +153,16 @@ export function manifest(baseUrl) {
theme_color: '#00ff41',
icons: [
{
src: `${baseUrl}/assets/yoonect-logo.png`,
sizes: '1447x712',
src: `${baseUrl}${FAVICON_PATH}/web-app-manifest-192x192.png?v=${FAVICON_VERSION}`,
sizes: '192x192',
type: 'image/png',
purpose: 'any'
purpose: 'maskable'
},
{
src: `${baseUrl}${FAVICON_PATH}/web-app-manifest-512x512.png?v=${FAVICON_VERSION}`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
};
@@ -161,6 +195,12 @@ export function sitemapXml(baseUrl, memes) {
` <lastmod>${xmlEscape(latest)}</lastmod>`,
' </url>'
].join('\n'),
[
' <url>',
` <loc>${xmlEscape(`${baseUrl}/lore`)}</loc>`,
` <lastmod>${xmlEscape(latest)}</lastmod>`,
' </url>'
].join('\n'),
...memes.slice(0, 1000).map((meme) => [
' <url>',
` <loc>${xmlEscape(`${baseUrl}/meme/${meme.id}`)}</loc>`,
+50 -1
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { feedJson, renderIndex, sitemapXml } from '../src/seo.js';
import fs from 'node:fs';
import { feedJson, manifest, renderIndex, sitemapXml } from '../src/seo.js';
const id = 'a'.repeat(64);
const meme = {
@@ -8,6 +9,7 @@ const meme = {
createdAt: '2026-07-24T12:00:00.000Z',
width: 1200,
height: 900,
mime: 'image/webp',
moderationScore: 92
};
const template = [
@@ -34,10 +36,57 @@ test('renders a meme permalink with meme-specific share metadata', () => {
assert.match(html, /nonce="test-nonce"/);
});
test('renders the homepage with the dedicated social preview image', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'home-nonce',
approvedCount: 12
});
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/);
assert.doesNotMatch(html, /yoonect-logo\.png/);
});
test('uses the supplied favicon suite in the page and web app manifest', () => {
const index = fs.readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
const appManifest = manifest('https://memes.example');
assert.match(index, /\/assets\/favicon\/favicon\.svg\?v=20260814/);
assert.match(index, /\/assets\/favicon\/favicon-96x96\.png\?v=20260814/);
assert.match(index, /\/favicon\.ico\?v=20260814/);
assert.match(index, /\/assets\/favicon\/apple-touch-icon\.png\?v=20260814/);
assert.doesNotMatch(index, /rel="icon"[^>]+yoonect-logo\.png/);
assert.deepEqual(appManifest.icons.map(({ sizes, purpose }) => ({ sizes, purpose })), [
{ sizes: '192x192', purpose: 'maskable' },
{ sizes: '512x512', purpose: 'maskable' }
]);
assert.match(appManifest.icons[0].src, /web-app-manifest-192x192\.png\?v=20260814$/);
assert.match(appManifest.icons[1].src, /web-app-manifest-512x512\.png\?v=20260814$/);
});
test('renders a shareable lore page with text-file metadata', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'lore-nonce',
approvedCount: 1,
lore: true
});
assert.match(html, /<title>Lore \| The Meme Protocol<\/title>/);
assert.match(html, /canonical" href="https:\/\/memes\.example\/lore"/);
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v1\.png"/);
assert.match(html, /"@type":"CreativeWork"/);
assert.match(html, /https:\/\/memes\.example\/assets\/meme-protocol-lore\.txt/);
});
test('discovery feeds point readers to viewer permalinks', () => {
const feed = feedJson('https://memes.example', [meme]);
const sitemap = sitemapXml('https://memes.example', [meme]);
assert.match(sitemap, /https:\/\/memes\.example\/lore/);
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}`));