Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ npm run dev
This starts a local server at `http://localhost:8090` with live SCSS recompilation. JavaScript source files run directly. refresh after editing them.

Build deployable assets with `npm run build`. output is written to `build/`.
The build inlines the small stylesheet and early theme script to avoid blocking
requests, and adds content hashes to asset URLs for safe browser caching.
The website Caddy configuration compresses text responses and caches versioned
assets for one year. HTML and unversioned assets revalidate on each visit.

## Design and assets

Expand All @@ -29,10 +33,11 @@ Without JavaScript, the system theme, navigation, screenshot and download links
remain usable. Reduced-motion preferences disable smooth scrolling.

Brand SVGs in `public/img/` are copied from the client's `public/img/` assets.
The library screenshot is the original client README image, with local WebP
variants at 640, 1280, 1920 and 2560 pixels. The full-resolution PNG is available
through the enlargement link and is the social preview image. It always shows
the real dark-mode app, independent of the website theme. Favicons use the
The library screenshots show the real app in light and dark modes, with local
WebP variants at 640, 768, 1280, 1920 and 2560 pixels. The screenshot follows the
website theme. Clicking it opens an on-page preview of the full-resolution JPEG,
which can be closed by tapping the image, pressing Escape, or clicking outside it.
The light screenshot is the social preview image. Favicons use the
canonical emblem from the client's `app/assets/images/logo-icon-light.svg`.
These are checked-in assets. website builds do not need the client repository
or image-generation tools.
Expand Down Expand Up @@ -71,7 +76,7 @@ An administrator installs `deploy/compose.yml` at
dedicated upload account. After installing the first static build into
`site/releases/<commit>` and linking `site/current` to that relative path, run
`docker compose -f compose.yml up -d --wait` from this directory. The shared
proxy/network must be provisioned first from the workspace's `deploy/edge`
proxy/network must be provisioned first from `PapyrusReader/observability` (`observability/edge` in the workspace)
runbook. Website containers publish no host ports. They do not join the app's
private network or mount its files.

Expand Down
8 changes: 8 additions & 0 deletions deploy/Caddyfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
:8080 {
encode zstd gzip

@status path /status.json
handle @status {
root * /srv/status
Expand All @@ -7,6 +9,12 @@
}
handle {
root * /srv/site/current
@versioned {
path /css/* /js/* /public/*
query v=*
}
header @versioned Cache-Control "public, max-age=31536000, immutable"
header ?Cache-Control "no-cache"
file_server
}
}
37 changes: 22 additions & 15 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
content="Organize physical and digital books with Papyrus, a free, open-source e-book reader and library manager. Read offline or sync across devices.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://papyrus-reader.com/">
<meta property="og:image" content="https://papyrus-reader.com/public/img/library.png">
<meta property="og:image" content="https://papyrus-reader.com/public/img/library-light.jpg">
<meta property="og:image:width" content="2560">
<meta property="og:image:height" content="1440">
<meta property="og:image:alt" content="The Papyrus library, showing physical books and ebooks together">
Expand Down Expand Up @@ -43,7 +43,7 @@
"operatingSystem": "Windows, Linux",
"browserRequirements": "A modern web browser for the web app.",
"downloadUrl": "https://github.com/PapyrusReader/client/releases/latest",
"screenshot": "https://papyrus-reader.com/public/img/library.png",
"screenshot": "https://papyrus-reader.com/public/img/library-light.jpg",
"license": "https://github.com/PapyrusReader/client/blob/master/LICENSE",
"isAccessibleForFree": true,
"offers": { "@type": "Offer", "price": 0 },
Expand Down Expand Up @@ -84,6 +84,7 @@
<div class="nav-links" id="nav-links">
<a href="#about">About</a>
<a href="#downloads">Downloads</a>
<a href="https://docs.papyrus-reader.com/">Docs</a>
<a href="/status/">Status</a>
<a href="https://github.com/PapyrusReader/papyrus">GitHub <span aria-hidden="true">↗</span></a>
</div>
Expand All @@ -92,26 +93,32 @@
<main id="main">
<section class="hero container" aria-labelledby="hero-title">
<div class="hero-copy">
<h1 id="hero-title">Your books, your data,<br>every device.</h1>
<p class="hero-description">Papyrus is a free, open-source app for organizing physical and digital books, reading e-books, and syncing your library across devices.</p>
<h1 id="hero-title">Your books, your data,<br><span class="marker-emphasis">every</span> device.</h1>
<p class="hero-description">Papyrus is a free, open-source app that brings your physical and digital books into one library. Access your books across devices, track your reading habits, and more.</p>
<div class="hero-actions">
<a class="button button-primary" href="https://app.papyrus-reader.com">Try in browser <span
aria-hidden="true">↗</span></a>
<a class="text-link" href="#downloads">Downloads <span aria-hidden="true">↓</span></a>
</div>
</div>
<figure class="library-preview">
<a href="public/img/library.png" target="_blank" rel="noopener"
aria-label="Open the full-size Papyrus library screenshot in a new tab">
<img src="public/img/library-1280.webp"
srcset="public/img/library-640.webp 640w, public/img/library-1280.webp 1280w, public/img/library-1920.webp 1920w, public/img/library-2560.webp 2560w"
sizes="(max-width: 720px) calc(100vw - 40px), (max-width: 1340px) calc(100vw - 80px), 1260px" width="2560"
height="1440" alt="Papyrus in dark mode, with physical books and ebooks displayed together in a cover grid"
fetchpriority="high">
<a href="public/img/library-light.jpg" data-library-full-size
aria-label="Preview the Papyrus library screenshot" aria-haspopup="dialog" aria-controls="library-dialog">
<picture>
<source data-library-dark media="(prefers-color-scheme: dark)"
srcset="public/img/library-dark-640.webp 640w, public/img/library-dark-768.webp 768w, public/img/library-dark-1280.webp 1280w, public/img/library-dark-1920.webp 1920w, public/img/library-dark-2560.webp 2560w"
sizes="(max-width: 720px) calc(100vw - 40px), (max-width: 1340px) calc(100vw - 80px), 1260px">
<img src="public/img/library-light-1280.webp"
srcset="public/img/library-light-640.webp 640w, public/img/library-light-768.webp 768w, public/img/library-light-1280.webp 1280w, public/img/library-light-1920.webp 1920w, public/img/library-light-2560.webp 2560w"
sizes="(max-width: 720px) calc(100vw - 40px), (max-width: 1340px) calc(100vw - 80px), 1260px" width="2560"
height="1440" alt="Papyrus library with physical books and ebooks displayed together in a cover grid"
fetchpriority="high">
</picture>
</a>
<figcaption><a href="public/img/library.png" target="_blank" rel="noopener">View full size <span
aria-hidden="true">↗</span></a></figcaption>
</figure>
<dialog id="library-dialog" class="library-dialog" aria-label="Papyrus library screenshot preview">
<img width="2560" height="1440" alt="Papyrus library screenshot">
</dialog>
</section>
<section class="product container" id="about" aria-labelledby="about-title">
<h2 id="about-title">Features</h2>
Expand Down Expand Up @@ -151,7 +158,7 @@ <h3>Book formats</h3>
<span class="feature-icon" aria-hidden="true"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="7" rx="2"/><rect x="3" y="14" width="18" height="7" rx="2"/><path d="M7 6.5h.01M7 17.5h.01M12 6.5h5M12 17.5h5"/></svg></span>
<h3>Storage options</h3>
</div>
<p>Keep books on your device, use a Papyrus server, or connect Google Drive, OneDrive, Dropbox, WebDAV, or S3. Use the hosted service or <a href="https://papyrusreader.github.io/docs/">run your own server</a>.</p>
<p>Keep books on your device, use a Papyrus server, or connect Google Drive, OneDrive, Dropbox, WebDAV, or S3. Use the hosted service or <a href="https://docs.papyrus-reader.com/">run your own server</a>.</p>
</article>
</div>
</section>
Expand Down Expand Up @@ -209,7 +216,7 @@ <h2 id="open-source-title">Free and open source</h2>
Contributions and bug reports are welcome.</p>
<div class="inline-links"><a class="text-link" href="https://github.com/PapyrusReader/papyrus">Source code <span
aria-hidden="true">↗</span></a><a class="text-link"
href="https://papyrusreader.github.io/docs/">Documentation <span aria-hidden="true">↗</span></a></div>
href="https://docs.papyrus-reader.com/">Documentation <span aria-hidden="true">↗</span></a></div>
</div>
</section>
</main>
Expand Down
32 changes: 28 additions & 4 deletions js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,32 @@
if (!links.contains(event.target) && !menuButton.contains(event.target)) closeMenu();
});
window.matchMedia('(max-width: 720px)').addEventListener('change', closeMenu);
const updateThemeButton = () => {
const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
const libraryPreview = document.querySelector('.library-preview');
const libraryDialog = document.getElementById('library-dialog');
const updatePreviewImage = () => {
const image = libraryDialog.querySelector('img');
image.src = libraryPreview.querySelector('[data-library-full-size]').href;
image.alt = libraryPreview.querySelector('img').alt;
};
if (libraryPreview && libraryDialog) {
libraryPreview.querySelector('[data-library-full-size]').addEventListener('click', (event) => {
event.preventDefault();
updatePreviewImage();
libraryDialog.showModal();
});
libraryDialog.addEventListener('click', () => libraryDialog.close());
}
const updateThemeUI = () => {
const theme = document.documentElement.dataset.theme;
const next = theme === 'dark' ? 'light' : 'dark';
if (libraryPreview) {
libraryPreview.querySelector('[data-library-dark]').media = theme === 'dark' ? 'all' : 'not all';
libraryPreview.querySelector('img').alt = `Papyrus in ${theme} mode, with physical books and ebooks displayed together in a cover grid`;
libraryPreview.querySelectorAll('[data-library-full-size]').forEach((link) => {
link.href = `public/img/library-${theme}.jpg`;
});
if (libraryDialog && libraryDialog.open) updatePreviewImage();
}
themeLabel.textContent = next === 'dark' ? 'Dark' : 'Light';
themeButton.querySelector('svg').innerHTML = next === 'dark'
? '<path d="M20.8 13A9 9 0 0 1 11 3.2 9 9 0 1 0 20.8 13Z"/>'
Expand All @@ -35,8 +59,8 @@
};
if (window.PapyrusTheme) {
themeButton.hidden = false;
updateThemeButton();
updateThemeUI();
themeButton.addEventListener('click', () => window.PapyrusTheme.toggle());
window.addEventListener('papyrus:themechange', updateThemeButton);
window.addEventListener('papyrus:themechange', updateThemeUI);
}
})();
44 changes: 44 additions & 0 deletions js/tests/build.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');

test('built pages inline critical assets and invalidate cached URLs when asset contents change', () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'papyrus-build-test-'));
const write = (name, contents) => fs.writeFileSync(path.join(directory, name), contents);
const read = (name) => fs.readFileSync(path.join(directory, name), 'utf8');
try {
for (const name of ['build/css', 'build/js', 'build/status', 'public/img']) {
fs.mkdirSync(path.join(directory, name), { recursive: true });
}
write('build/css/style.css', 'body{color:red}');
write('build/js/theme.js', 'window.theme = "$&";');
write('build/js/main.js', 'window.ready = true;');
write('public/img/book.webp', 'first image');
const build = () => {
for (const [file, prefix] of [['index.html', ''], ['status/index.html', '/']]) {
write(`build/${file}`, `<script src="${prefix}js/theme.js"></script><link rel="stylesheet" href="${prefix}css/style.css"><script src="${prefix}js/main.js" defer></script><img src="${prefix}public/img/book.webp" srcset="${prefix}public/img/book.webp 768w">`);
}
execFileSync(process.execPath, [path.resolve(__dirname, '../../scripts/stamp-version.js')], { cwd: directory });
};
build();
const before = read('build/index.html');
for (const file of ['build/index.html', 'build/status/index.html']) {
const html = read(file);
assert.ok(html.includes('<style>body{color:red}</style>'));
assert.ok(html.includes('<script>window.theme = "$&";</script>'));
assert.doesNotMatch(html, /src="[^"]*theme\.js|rel="stylesheet"/);
assert.match(html, /book\.webp\?v=[a-f0-9]{16} 768w/);
assert.match(html, /main\.js\?v=[a-f0-9]{16}" defer/);
}
write('public/img/book.webp', 'replacement image');
build();
const after = read('build/index.html');
assert.notEqual(before.match(/book\.webp\?v=\w+/)[0], after.match(/book\.webp\?v=\w+/)[0]);
assert.equal(before.match(/main\.js\?v=\w+/)[0], after.match(/main\.js\?v=\w+/)[0]);
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
});
Loading
Loading