openvibers/openvibe.blog Commits

Use shared Publishing block styles in Blog (#259)

OP OpenVibers committed 3 days ago · a5601f195e · 12 files, +34 −105

Changes

ddiff --git a/README.md b/README.md
iindex ae26c37..7fd8efa 100644
---- a/README.md
++++ b/README.md
@@@ -345,7 +345,7 @@ Called elsewhere, as the service principal `blog`: `identity.subject.resolve` an
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through
`openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional: without `VALKEY_URL` they count per process).
--- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.6.1 (async PostgreSQL stores: revisions,
++- **Packages** (all pinned by release tarball): `openvibe-publishing` v1.7.0 (async PostgreSQL stores: revisions,
schedule, taxonomy, citations, media, discussion, seo, authorship, index-hooks, ssr, layout), `openvibe-contracts`
v0.97.0, `openvibe-shared` v4.179.0 (Frame, app icon, footer, legal, release, metrics, ready, theme presets),
`openvibe-sdk` v0.133.0 (sso, db, PostgreSQL events outbox, service tokens, per-actor limits, testing).
@@@ -559,5 +559,5 @@ Part of the [OpenVibe network](https://openvibe.network). Built in the open by [
- openvibe-contracts: v0.373.0
- openvibe-sdk: v0.133.0
- openvibe-shared: v4.179.0
--- openvibe-publishing: v1.6.1
++- openvibe-publishing: v1.7.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 3f22232..ebe7361 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -11,7 +11,7 @@
"sdk": "openvibe-sdk v0.133.0",
"shared": "openvibe-shared v4.179.0",
"packages": [
-- "openvibe-publishing v1.6.1",
++ "openvibe-publishing v1.7.0",
"openvibe-contracts v0.373.0",
"openvibe-shared v4.179.0",
"openvibe-sdk v0.133.0"
ddiff --git a/package-lock.json b/package-lock.json
iindex 0bf1d2e..e269e5e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.6.1",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
@@@ -1164,9 +1164,9 @@
}
},
"node_modules/openvibe-publishing": {
-- "version": "1.6.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.6.1",
-- "integrity": "sha512-47H8yCGpqPFhWwK1SZ0GkvYh+JpwbleamnEqBvhXAwj3Ep176b2J7rcCrQEqgQqpVr8QPrm2aTdTNq3k3mGCbg==",
++ "version": "1.7.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
++ "integrity": "sha512-65DspLWSuy6KO1uzR88aLIVfRhEpC4J3+xxK34rcj/VoEaCSV6dTBjDqlSwM/e3Cj2SPoVj4QT/qsUkx7VDt6w==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 957b54c..106991f 100644
---- a/package.json
++++ b/package.json
@@@ -29,7 +29,7 @@
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
-- "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.6.1",
++ "openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.7.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.133.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.179.0",
"pg": "^8.23.0"
ddiff --git a/public/css/blocks.css b/public/css/blocks.css
iindex 006de55..8f1e89b 100644
---- a/public/css/blocks.css
++++ b/public/css/blocks.css
@@@ -1,11 +1,7 @@
--/* Post reading layout, rich blocks and the editor. Loaded only where bodies are rendered. */
--.post-body img, .ov-media img { max-width: 100%; height: auto; border-radius: 8px; }
++/* Post reading layout and editor. Loaded only where bodies are rendered. */
++.post-body img { max-width: 100%; height: auto; border-radius: 8px; }
.post-body pre { overflow-x: auto; background: var(--bg-tertiary, #172238); padding: 12px; border-radius: 8px; }
.post-body blockquote { border-left: 3px solid var(--accent, #3b82f6); margin: 0; padding-left: 14px; color: var(--text-secondary); }
--.ov-media { margin: 1.2rem 0; }
--.ov-media figcaption { font-size: .9rem; color: var(--text-muted); }
--.ov-media-missing { padding: 28px 16px; border: 1px dashed var(--border-light, #2c3d5c); border-radius: 8px; text-align: center; color: var(--text-secondary); background: var(--bg-tertiary, #172238); }
--
/* ── The reading layout (the blog outcomes, research-2026-10-30/market/outcomes/blog.md): a 65-character measure at
18 px, mobile leading at 1.5, a stated read time, the post's own sections as a contents list (a fold under the title
on phones, a rail to the left of the text from 1100 px), and the series as one line above and its neighbours below. */
@@@ -52,104 +48,17 @@ body.post-page .page { max-width: 1160px; }
.toc-rail a:hover { color: var(--text-primary); border-left-color: var(--accent, #3b82f6); }
}
--/* ── Rich blocks (stage B1): the blocks of a body, server-rendered. Every block has a plain-HTML form,
-- so a reader without JavaScript sees the same thing. A section is a block: a big heading, a byline and
-- the reader's own read on it (👍 or 👎) at the end. The contents rail stands beside the text on a
-- computer; on a phone the sections are a row of chips under the title. */
++/* The post header and its contents list belong to Blog. */
.post-header { margin-bottom: 4px; }
.post-title { font-size: clamp(1.9rem, 1.35rem + 2.2vw, 3rem); line-height: 1.12; letter-spacing: -.02em; margin: 0 0 10px; }
.post-teaser { margin: 0 0 10px; max-width: 62ch; color: var(--text-secondary, #96a7c2); font-size: 1.12rem; line-height: 1.5; }
.post-hero-art { margin: 18px 0 4px; }
.post-hero-art img { display: block; width: 100%; height: auto; border-radius: 16px; }
--/* A section: the heading is big, the byline says who wrote it, and the reactions sit at its end. */
--.ov-section { margin: 34px 0; scroll-margin-top: calc(var(--ovnav-real-h, 56px) + 16px); }
--.ov-section > h2 { font-size: clamp(1.5rem, 1.2rem + 1.1vw, 2rem); line-height: 1.2; letter-spacing: -.015em; margin: 0 0 6px; }
--.ov-section > h2:target { color: var(--accent, #3b82f6); }
--.ov-section-by { margin: 0 0 14px; color: var(--text-muted, #7386a3); font-size: .9rem; }
--.ov-section-by a { color: var(--text-secondary, #96a7c2); }
--.ov-hero { margin: 0 0 6px; }
--.ov-hero .ov-kicker { margin: 0; color: var(--accent, #3b82f6); font-size: .86rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
--.ov-hero-date { margin: 2px 0 0; color: var(--text-muted, #7386a3); font-size: .86rem; }
--
--/* The reader's read on a section: a form that posts (it works without script) and the counts in place. */
--.ov-reactions { display: flex; align-items: center; gap: 8px; margin: 18px 0 0; }
--.ov-reactions button { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 0 14px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; background: var(--bg-card, #131c2e); color: var(--text-primary); font-size: .95rem; cursor: pointer; }
--.ov-reactions button:hover { border-color: var(--accent, #3b82f6); }
--.ov-react-count { color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; }
--.ov-react-signin { min-height: 44px; display: inline-flex; align-items: center; color: var(--text-secondary, #96a7c2); font-size: .92rem; }
--
--/* The other blocks: a callout, a stat, a quote, a compare, a gallery, a patch-notes fold, a timeline, an infobox. */
--.ov-callout { margin: 26px 0; padding: 16px 18px; border-left: 4px solid var(--accent, #3b82f6); border-radius: 10px; background: var(--bg-card, #131c2e); }
--.ov-callout-warn { border-left-color: #f59e0b; }
--.ov-callout-tip { border-left-color: #22c55e; }
--.ov-callout-title { margin: 0 0 6px; font-weight: 700; }
--.ov-stat { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px; margin: 26px 0; }
--.ov-stat-value { font-size: 2.2rem; font-weight: 800; letter-spacing: -.02em; }
--.ov-stat-label { color: var(--text-secondary, #96a7c2); font-size: 1.05rem; }
--.ov-quote { margin: 26px 0; padding: 4px 0 4px 18px; border-left: 3px solid var(--border, #1f2d47); }
--.ov-quote-by { margin: 8px 0 0; color: var(--text-muted, #7386a3); font-size: .9rem; }
--.ov-compare { margin: 26px 0; }
--.ov-compare-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr)); gap: 14px; }
--.ov-gallery { margin: 26px 0; }
--.ov-gallery-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(220px, 100%), 1fr)); gap: 12px; }
--.ov-media-credit { display: block; margin-top: 6px; color: var(--text-muted, #7386a3); font-size: .82rem; }
--.ov-patchnotes { margin: 26px 0; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
--.ov-patchnotes > summary { display: flex; align-items: center; min-height: 46px; padding: 0 14px; font-weight: 700; cursor: pointer; list-style: none; }
--.ov-patchnotes > summary::-webkit-details-marker { display: none; }
--.ov-patchnotes-site { padding: 0 14px 10px; }
--.ov-patchnotes-site h3 { margin: 12px 0 4px; font-size: 1rem; }
--.ov-patchnotes-site ul { margin: 0; padding-left: 20px; }
--.ov-infobox, .ov-timeline { margin: 26px 0; padding: 16px 18px; border: 1px solid var(--border, #1f2d47); border-radius: 12px; background: var(--bg-card, #131c2e); }
--.ov-infobox dl { margin: 0; }
--.ov-infobox-row { display: flex; gap: 10px; padding: 4px 0; border-top: 1px solid var(--border, #1f2d47); }
--.ov-infobox-key { color: var(--text-muted, #7386a3); }
--.ov-infobox-value { margin: 0; }
--.ov-timeline ol { margin: 0; padding-left: 20px; }
--.ov-timeline-when { color: var(--text-muted, #7386a3); font-variant-numeric: tabular-nums; }
--.ov-timeline-text { margin-left: 8px; }
--
/* The contents: a rail beside the text on a computer, a row of chips under the title on a phone. */
.toc-chips { display: flex; gap: 8px; overflow-x: auto; margin: 14px 0 4px; padding-bottom: 4px; scrollbar-width: thin; }
.toc-chip { flex: none; display: inline-flex; align-items: center; min-height: 44px; padding: 0 12px; border: 1px solid var(--border, #1f2d47); border-radius: 999px; color: var(--text-secondary, #96a7c2); font-size: .88rem; text-decoration: none; white-space: nowrap; }
@media (min-width: 1100px) { .toc-chips { display: none; } }
--/* ── Devblog look (Opus, 11-09: "Rust-style devblogs… named updates with title art… epic") ── */
--/* The hero is a banner: its title art fills it (a dark wash keeps the words readable), or an accent wash without art. The
-- kicker is a pill beside the date, and the title is the biggest thing on the page. */
--.ov-hero { position: relative; isolation: isolate; overflow: hidden; display: grid; gap: 10px; margin: 0 0 28px; padding: clamp(22px, 4vw, 40px);
-- border-radius: 22px; border: 1px solid var(--border, #1f2d47);
-- background: radial-gradient(120% 140% at 0% 0%, color-mix(in srgb, var(--accent, #3b82f6) 34%, transparent), transparent 62%),
-- linear-gradient(160deg, color-mix(in srgb, var(--accent, #3b82f6) 14%, var(--bg-card, #131c2e)), var(--bg-card, #131c2e)); }
--.ov-hero .ov-hero-art { position: absolute; inset: 0; z-index: -1; margin: 0; }
--.ov-hero .ov-hero-art img { width: 100%; height: 100%; object-fit: cover; }
--.ov-hero .ov-hero-art::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(4, 8, 18, .18) 0%, rgba(4, 8, 18, .78) 100%); }
--.ov-hero:has(.ov-hero-art) { min-height: clamp(260px, 42vw, 420px); align-content: end; color: #fff; border-color: transparent; }
--.ov-hero:has(.ov-hero-art) .ov-hero-body p { color: rgba(255, 255, 255, .86); }
--.ov-hero .ov-kicker { justify-self: start; display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; font-size: .78rem;
-- font-weight: 750; letter-spacing: .02em; text-transform: none; color: var(--accent, #3b82f6); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); }
--.ov-hero .ov-kicker::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
--.ov-hero:has(.ov-hero-art) .ov-kicker { color: #fff; background: rgba(255, 255, 255, .18); backdrop-filter: blur(6px); }
--.ov-hero-date { font-size: .86rem; color: var(--text-muted, #7386a3); }
--.ov-hero:has(.ov-kicker) .ov-hero-date { grid-row: 1; justify-self: start; margin: 0 0 0 0; padding: 4px 0; }
--.ov-hero:has(.ov-kicker) { grid-template-columns: auto 1fr; column-gap: 10px; }
--.ov-hero:has(.ov-kicker) .ov-kicker { grid-row: 1; grid-column: 1; }
--.ov-hero:has(.ov-kicker) .ov-hero-date { grid-column: 2; }
--.ov-hero:has(.ov-kicker) .ov-hero-body { grid-column: 1 / -1; }
--.ov-hero-body > :is(h1, h2) { margin: 4px 0 10px; font-size: clamp(2rem, 1.35rem + 3.2vw, 3.4rem); line-height: 1.06; letter-spacing: -.028em; font-weight: 800; }
--.ov-hero-body > p { margin: 0; max-width: 62ch; font-size: clamp(1.02rem, .95rem + .4vw, 1.2rem); line-height: 1.55; color: var(--text-secondary, #96a7c2); }
--/* The timeline: a line with a dot per moment, the time in tabular numbers, the words beside it. */
--.ov-timeline ol { position: relative; list-style: none; margin: 4px 0 0; padding: 0; }
--.ov-timeline ol::before { content: ""; position: absolute; left: 5px; top: 10px; bottom: 10px; width: 2px; border-radius: 2px; background: color-mix(in srgb, var(--accent, #3b82f6) 30%, transparent); }
--.ov-timeline-item { position: relative; display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 12px; padding: 7px 0 7px 24px; }
--.ov-timeline-item::before { content: ""; position: absolute; left: 0; top: 13px; width: 12px; height: 12px; border-radius: 50%; background: var(--bg-card, #131c2e); box-shadow: inset 0 0 0 3px var(--accent, #3b82f6); }
--.ov-timeline-when { min-width: 3.4em; font-weight: 700; color: var(--accent, #3b82f6); }
--.ov-timeline-text { margin-left: 0; }
--.ov-timeline-title, .ov-infobox-title { margin: 0 0 8px; font-size: 1.05rem; }
--/* A stat reads as a figure, in the accent. */
--.ov-stat-value { color: var(--accent, #3b82f6); font-size: clamp(2.2rem, 1.8rem + 2vw, 3rem); line-height: 1; }
--.ov-infobox-row { display: grid; grid-template-columns: minmax(84px, 32%) minmax(0, 1fr); gap: 12px; padding: 7px 0; }
--.ov-infobox-key { margin: 0; }
--
/* ── The block inserter (public/js/blog-editor.js): a "/" menu, a form per block with a live preview, an
outline dragged to reorder, picture search and Make an image. Progressive only: the textarea saves. ── */
.ov-editor { margin: 6px 0 4px; }
ddiff --git a/server/app.js b/server/app.js
iindex bf1abdd..7857174 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -216,6 +216,11 @@ async function createApp(opts = {}) {
// ── Static assets (content-hashed ?v= → immutable) ──────
// This site's own pinned copy of the OpenVibe Frame's browser files (openvibe-shared/serve).
app.use('/shared', require('openvibe-shared/serve').handler());
++ app.get('/css/publishing-blocks.css', (req, res) => {
++ const v = req.query.v;
++ res.type('css').set('Cache-Control', cache.assetHeaders('css/publishing-blocks.css', { hashed: !!v && v === assetVersion('css/publishing-blocks.css') }))
++ .sendFile(require.resolve('openvibe-publishing/blocks.css'));
++ });
app.use(express.static(PUBLIC_DIR, {
index: false, redirect: false,
setHeaders(res, filePath) {
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex 8a4c832..72852e5 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -70,7 +70,8 @@ function aiDraftForm({ blog, csrf }) {
function postForm({ blog, post, head, terms, series, csrf, message, action }) {
const f = head ? head.fields : {};
const vis = post ? post.visibility : 'public';
-- return String(h`<link rel="stylesheet" href="${asset('css/blocks.css')}">
++ return String(h`<link rel="stylesheet" href="${asset('css/publishing-blocks.css')}">
++<link rel="stylesheet" href="${asset('css/blocks.css')}">
${flash(message)}
<form method="post" action="${action}" class="post-form">${csrfField(csrf)}
${head ? h`<input type="hidden" name="expectedRevision" value="${head.number}">` : ''}
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex 56c0445..815852c 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -22,12 +22,13 @@ const SITE_NAME = 'OpenVibe.Blog';
// One site summary, shared by /llms.txt, /llms-full.txt and the home page's ai-summary.
const SITE_SUMMARY = 'The official OpenVibe blog and a blog for every OpenVibe member: server-rendered posts with feeds, sitemaps and a JSON representation of every post.';
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
++const PUBLISHING_BLOCKS_CSS = require.resolve('openvibe-publishing/blocks.css');
const hashes = new Map();
function assetVersion(rel) {
if (hashes.has(rel)) return hashes.get(rel);
let v = 'dev';
-- try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
++ try { v = crypto.createHash('sha256').update(fs.readFileSync(rel === 'css/publishing-blocks.css' ? PUBLISHING_BLOCKS_CSS : path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
hashes.set(rel, v);
return v;
}
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 2d66570..2a89c5e 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -226,7 +226,8 @@ function postPage(o) {
// summary only shows when the hero has no prose of its own.
const heroHtml = rendered.heroHtml || null;
const heroTitle = rendered.heroHeading || null;
-- return String(h`<link rel="stylesheet" href="${asset('css/blocks.css')}">
++ return String(h`<link rel="stylesheet" href="${asset('css/publishing-blocks.css')}">
++<link rel="stylesheet" href="${asset('css/blocks.css')}">
${raw(ssr.breadcrumbsHtml(o.breadcrumbs))}
<div class="post-wrap${toc ? ' has-toc' : ''}">
${raw(toc ? contentsHtml(toc) : '')}
ddiff --git a/test/asset-cache.test.js b/test/asset-cache.test.js
iindex 28b992f..f7818b7 100644
---- a/test/asset-cache.test.js
++++ b/test/asset-cache.test.js
@@@ -29,6 +29,14 @@ const REVALIDATED = 'public, max-age=300, stale-while-revalidate=86400';
assert.strictEqual(r.status, 200);
assert.strictEqual(r.headers.get('cache-control'), REVALIDATED);
});
++ await check('the library block sheet is content-addressed and served as CSS', async () => {
++ const rel = 'css/publishing-blocks.css';
++ const r = await t.get(`/${rel}?v=${assetVersion(rel)}`);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('content-type'), /^text\/css/);
++ assert.strictEqual(r.headers.get('cache-control'), IMMUTABLE);
++ assert.match(r.text, /\.ov-infobox/);
++ });
await t.close();
done();
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex bcb1c2a..d63632d 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -90,6 +90,9 @@ const body = (by) => [
await check('the published page renders the blocks: the hero art, the section, the byline, the rail', async () => {
await t.get(`/api/v1/posts/${post.id}/publish`, { as: alice, json: {} });
const page = await t.get('/@alice/blocks');
++ const sharedCss = page.text.match(/<link rel="stylesheet" href="(\/css\/publishing-blocks\.css\?v=[0-9a-f]{10})">/);
++ assert.ok(sharedCss, 'the post links the library blocks sheet');
++ assert.ok(page.text.indexOf(sharedCss[0]) < page.text.indexOf('<link rel="stylesheet" href="/css/blocks.css?'), 'the library sheet loads before Blog placement rules');
assert.strictEqual(page.status, 200, page.text);
assert.match(page.text, /class="ov-media ov-hero-art"/);
assert.match(page.text, /id="blueprint-rework"/);
ddiff --git a/test/nojs-editor.test.js b/test/nojs-editor.test.js
iindex 7847973..14c8590 100644
---- a/test/nojs-editor.test.js
++++ b/test/nojs-editor.test.js
@@@ -58,6 +58,7 @@ const idFrom = (location) => (location.match(/\/write\/posts\/(pst_[0-9A-Z]+)/)
await check('new draft, edit and publish with forms', async () => {
const form = await t.get('/write/@eve/new', { as: eve });
assert.match(form.text, /<textarea id="body" name="body"/);
++ assert.ok(form.text.indexOf('href="/css/publishing-blocks.css?v=') < form.text.indexOf('href="/css/blocks.css?v='), 'the editor loads shared block styles before its own');
let r = await t.get('/write/@eve/new', { as: eve, form: { _csrf: csrf, title: 'Notes without script', body: `# Heading\n\nPlain *text*. ${LONG}`, tags: 'nojs', series: 'Field notes', seriesPosition: '1', visibility: 'public', allowComments: '1' } });
assert.strictEqual(r.status, 303, r.text);
postId = idFrom(r.headers.get('location'));