diff --git a/README.md b/README.md
index 64a4165..84347a7 100644
--- a/README.md
+++ b/README.md
@@ -3,8 +3,10 @@
[](https://www.npmjs.com/package/@larvit/adf-codec)
[](https://github.com/larvit/adf-codec/actions/workflows/ci.yml)
-Convert between ADF (Atlassian Document Format) and markdown or HTML. For application developers
-whose app shows, edits, exports or generates the rich text Atlassian products store as ADF.
+Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML.
+
+For application developers whose app shows, edits, exports or generates the rich text Atlassian
+products store as ADF.
## Main functionality
@@ -784,6 +786,8 @@ All three commands need only Docker, git and bash 5.
- `./mutation-tests/run.sh []` mutation-tests the lines changed since ``, `origin/main`
by default, in source files git tracks. It needs 6 GB of memory for Docker, and lists the mutants
that survived. Survivors don't fail it; a mutant whose test run crashed does.
+- The README's first paragraph after the badges is the npm description: `./publish.sh` copies it to
+ `package.json`. Keep it plain text; `./ci.sh` fails where it isn't.
## Links
diff --git a/ci.sh b/ci.sh
index 0037fd2..40b3dc2 100755
--- a/ci.sh
+++ b/ci.sh
@@ -61,6 +61,7 @@ leg "tests ($deno_image)" in_image "$deno_image" deno test --allow-env=PROPERTY_
leg "tests ($bun_image)" in_image "$bun_image" bun test src/
leg "corpus-gathering tests ($node_image)" in_image "$node_image" node --test corpus-gathering/
leg "feature table tests ($node_image)" in_image "$node_image" node --test feature-table/
+leg "release tooling tests ($node_image)" in_image "$node_image" node --test release-tooling/
leg "build ($node_image)" in_image "$node_image" npm run build
leg "feature table is current ($node_image)" in_image "$node_image" node feature-table/build.mjs --check
diff --git a/package.json b/package.json
index 9a0cf50..1eace83 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "@larvit/adf-codec",
"version": "0.3.0",
- "description": "Convert Atlassian Document Format (ADF), the rich text of Jira and Confluence, to and from markdown, with a lossless round-trip, and to HTML. No React, no Atlaskit, no runtime dependencies: runs in Node, Forge, Deno, Bun and browsers.",
+ "description": "Convert between ADF (Atlassian Document Format, the rich text of Jira and Confluence) and markdown or HTML.",
"keywords": [
"adf",
"adf-to-html",
diff --git a/publish.sh b/publish.sh
index f1e9022..ee45a49 100755
--- a/publish.sh
+++ b/publish.sh
@@ -35,6 +35,8 @@ if [ -z "$published" ]; then
: "${NPM_TOKEN:?the publish needs NPM_TOKEN}"
leg "install ($node_image)" in_image "$node_image" npm ci
leg "build ($node_image)" in_image "$node_image" npm run build
+ # npmjs shows the README's tagline, which package.json's description is overwritten with here.
+ leg "copy the README tagline to the description ($node_image)" in_image "$node_image" node release-tooling/write-description.mjs
leg "publish $name@$version ($node_image)" \
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e NPM_TOKEN -v "$PWD:/app" -w /app --entrypoint sh "$node_image" -c \
'printf "//registry.npmjs.org/:_authToken=%s\n" "$NPM_TOKEN" > "$HOME/.npmrc" && npm publish --access public'
diff --git a/release-tooling/tagline.mjs b/release-tooling/tagline.mjs
new file mode 100644
index 0000000..79f3e18
--- /dev/null
+++ b/release-tooling/tagline.mjs
@@ -0,0 +1,12 @@
+const markup = /[`[\]*_<>]/
+
+// The README's first paragraph after the title and the badges.
+export function readmeTagline(readme) {
+ const lines = readme.split('\n').filter((line) => !line.startsWith('# ') && !line.startsWith('[!['))
+ const start = lines.findIndex((line) => line.trim() !== '')
+ if (start === -1 || lines[start].startsWith('#')) return { error: 'README.md has no tagline paragraph before its first section', ok: false }
+ const end = lines.findIndex((line, index) => index > start && line.trim() === '')
+ const tagline = lines.slice(start, end === -1 ? undefined : end).map((line) => line.trim()).join(' ')
+ if (markup.test(tagline)) return { error: `the README tagline is plain text, since npm shows it as is: ${tagline}`, ok: false }
+ return { ok: true, value: tagline }
+}
diff --git a/release-tooling/tagline.test.mjs b/release-tooling/tagline.test.mjs
new file mode 100644
index 0000000..565b208
--- /dev/null
+++ b/release-tooling/tagline.test.mjs
@@ -0,0 +1,31 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import { test } from 'node:test'
+
+import { readmeTagline } from './tagline.mjs'
+
+const title = '# @larvit/pkg — a title\n\n[](https://example.com)\n[](https://example.com)\n\n'
+
+test('is the first paragraph after the title and the badges', () => {
+ assert.deepEqual(readmeTagline(`${title}Convert A to B.\n\nFor people who need it.\n\n## Install\n`), { ok: true, value: 'Convert A to B.' })
+})
+
+test('joins a paragraph wrapped over lines with single spaces', () => {
+ assert.deepEqual(readmeTagline(`${title}Convert A to B,\nand back.\n\nMore.\n`), { ok: true, value: 'Convert A to B, and back.' })
+})
+
+test('refuses a README with no paragraph before its first section', () => {
+ assert.equal(readmeTagline(`${title}## Install\n`).ok, false)
+})
+
+test('refuses markup, which npm would show as raw characters', () => {
+ for (const markup of ['`code`', '[link](https://example.com)', '**bold**', '_em_', 'x']) {
+ assert.equal(readmeTagline(`${title}Convert ${markup}.\n`).ok, false, markup)
+ }
+})
+
+test('reads the real README', () => {
+ const tagline = readmeTagline(readFileSync(new URL('../README.md', import.meta.url), 'utf8'))
+ assert.ok(tagline.ok, tagline.error)
+ assert.ok(tagline.value.includes('Jira'))
+})
diff --git a/release-tooling/write-description.mjs b/release-tooling/write-description.mjs
new file mode 100644
index 0000000..b2065b9
--- /dev/null
+++ b/release-tooling/write-description.mjs
@@ -0,0 +1,12 @@
+import { readFileSync, writeFileSync } from 'node:fs'
+
+import { readmeTagline } from './tagline.mjs'
+
+const packagePath = new URL('../package.json', import.meta.url)
+const tagline = readmeTagline(readFileSync(new URL('../README.md', import.meta.url), 'utf8'))
+if (!tagline.ok) {
+ console.error(tagline.error)
+ process.exit(1)
+}
+const manifest = JSON.parse(readFileSync(packagePath, 'utf8'))
+writeFileSync(packagePath, `${JSON.stringify({ ...manifest, description: tagline.value }, null, 2)}\n`)