notion_publish 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE +21 -0
  4. data/README.md +221 -0
  5. data/docs/notion-publish-home-page.md +105 -0
  6. data/docs/usage.md +648 -0
  7. data/exe/notion-publish +6 -0
  8. data/lib/notion_publish/adopter.rb +77 -0
  9. data/lib/notion_publish/cli.rb +212 -0
  10. data/lib/notion_publish/client.rb +226 -0
  11. data/lib/notion_publish/commands/adopt.rb +114 -0
  12. data/lib/notion_publish/commands/command.rb +114 -0
  13. data/lib/notion_publish/commands/properties.rb +33 -0
  14. data/lib/notion_publish/commands/publish.rb +134 -0
  15. data/lib/notion_publish/commands/relink.rb +95 -0
  16. data/lib/notion_publish/commands/reporting.rb +57 -0
  17. data/lib/notion_publish/commands/republish.rb +156 -0
  18. data/lib/notion_publish/commands/status.rb +84 -0
  19. data/lib/notion_publish/commands/whoami.rb +27 -0
  20. data/lib/notion_publish/commands.rb +17 -0
  21. data/lib/notion_publish/decoration.rb +75 -0
  22. data/lib/notion_publish/document.rb +75 -0
  23. data/lib/notion_publish/errors.rb +62 -0
  24. data/lib/notion_publish/fixups.rb +139 -0
  25. data/lib/notion_publish/links.rb +65 -0
  26. data/lib/notion_publish/log.rb +49 -0
  27. data/lib/notion_publish/manifest.rb +224 -0
  28. data/lib/notion_publish/media.rb +90 -0
  29. data/lib/notion_publish/notion_digest.rb +23 -0
  30. data/lib/notion_publish/pool.rb +103 -0
  31. data/lib/notion_publish/progress.rb +103 -0
  32. data/lib/notion_publish/property_set.rb +116 -0
  33. data/lib/notion_publish/publisher.rb +475 -0
  34. data/lib/notion_publish/reference.rb +84 -0
  35. data/lib/notion_publish/resolver.rb +216 -0
  36. data/lib/notion_publish/schema.rb +206 -0
  37. data/lib/notion_publish/settings.rb +73 -0
  38. data/lib/notion_publish/sharing.rb +47 -0
  39. data/lib/notion_publish/status.rb +127 -0
  40. data/lib/notion_publish/target.rb +34 -0
  41. data/lib/notion_publish/uploader.rb +85 -0
  42. data/lib/notion_publish/users.rb +49 -0
  43. data/lib/notion_publish/version.rb +5 -0
  44. data/lib/notion_publish.rb +31 -0
  45. metadata +96 -0
data/docs/usage.md ADDED
@@ -0,0 +1,648 @@
1
+ # Usage reference
2
+
3
+ This is the full reference for `notion-publish`. For an overview and a quick start, see the
4
+ [README](../README.md).
5
+
6
+ - [Command summary](#command-summary)
7
+ - [Authentication](#authentication)
8
+ - [Choosing a destination](#choosing-a-destination)
9
+ - [Settings file](#settings-file)
10
+ - [Properties](#properties)
11
+ - [Title, icon, and cover](#title-icon-and-cover)
12
+ - [Republishing and the manifest](#republishing-and-the-manifest)
13
+ - [Publishing a set of files](#publishing-a-set-of-files)
14
+ - [Republishing everything](#republishing-everything)
15
+ - [Links between documents](#links-between-documents)
16
+ - [Adopting pages that already exist](#adopting-pages-that-already-exist)
17
+ - [Checking status](#checking-status)
18
+ - [Local images](#local-images)
19
+ - [What is changed before publishing](#what-is-changed-before-publishing)
20
+ - [Scripting and exit codes](#scripting-and-exit-codes)
21
+ - [How Notion treats Markdown](#how-notion-treats-markdown)
22
+ - [Known limits](#known-limits)
23
+
24
+ ## Command summary
25
+
26
+ notion-publish FILE [options] publish one Markdown file
27
+ notion-publish status [DIR] what would happen if you published everything
28
+ notion-publish republish [DIR] update every page notion-publish-manifest.yml tracks
29
+ notion-publish relink [DIR] fix links to documents published later
30
+ notion-publish adopt FILE [options] record a page this file already corresponds to
31
+ notion-publish properties [options] show the destination's schema
32
+ notion-publish --whoami show what the token authenticates as
33
+
34
+ Options:
35
+
36
+ | Option | Meaning |
37
+ |-------------------------------|-----------------------------------------------------------------|
38
+ | `-p`, `--parent VALUE` | Destination: an ID, a Notion URL, or an exact database name |
39
+ | `-d`, `--database NAME` | Destination by name, for a database whose name looks like an ID |
40
+ | `-P`, `--property NAME=VALUE` | Set a property; repeat for multi-valued properties |
41
+ | `--properties-json JSON` | Set properties from a JSON object |
42
+ | `-t`, `--title TITLE` | Page title |
43
+ | `--icon ICON` | Emoji, image URL, or path to a local image |
44
+ | `--cover COVER` | Image URL or path to a local image |
45
+ | `--keep-h1` | Keep the leading H1 in the body as well as using it as title |
46
+ | `--no-upload` | Do not upload local images |
47
+ | `--link` | Record the page in the manifest |
48
+ | `--no-link` | Do not read or write the manifest; always create a page |
49
+ | `--manifest PATH` | Use this manifest instead of looking for one |
50
+ | `-f`, `--force` | Overwrite a page that was edited in Notion |
51
+ | `--force-properties` | Reapply properties even when nothing changed |
52
+ | `-n`, `--dry-run` | Resolve and validate; write nothing |
53
+ | `--json` | One JSON object per document on stdout |
54
+ | `--local` | `status` only: do not ask Notion, compare local hashes only |
55
+ | `--untracked` | `status` only: list files that were never published |
56
+ | `--page URL_OR_ID` | `adopt` only: the page this file corresponds to |
57
+ | `-y`, `--yes` | `adopt` only: accept a title match without asking |
58
+ | `-j`, `--jobs N` | Pages to check at once (default 3; 1 runs one at a time) |
59
+ | `--no-progress` | Do not show a progress line in a terminal |
60
+ | `-v`, `--verbose` | List every file; `-vv` logs API requests; `-vvv` adds bodies |
61
+ | `--token TOKEN` | API token, instead of the environment |
62
+ | `--version`, `-h`, `--help` | |
63
+
64
+ ## Authentication
65
+
66
+ Create an internal connection (integration) at
67
+ [notion.so/profile/integrations](https://www.notion.so/profile/integrations) and give it the
68
+ **Read content**, **Update content**, and **Insert content** capabilities. Copy its token.
69
+
70
+ Set the token in the environment:
71
+
72
+ export NOTION_API_TOKEN=ntn_...
73
+
74
+ `NOTION_API_TOKEN` is checked first because Notion's own `ntn` CLI reads it. `NOTION_API_KEY` is
75
+ accepted as a fallback because Notion's quickstart tells people to set that one. `--token` also
76
+ works, but command-line arguments are visible to other users through the process list.
77
+
78
+ A connection can only see pages that are shared with it. In Notion, open the page or database you
79
+ will publish into, choose **••• → Connections**, and add the connection. Pages nested below it are
80
+ shared automatically. An inline database has no connection menu of its own, so share the page it
81
+ sits on.
82
+
83
+ Check what the token authenticates as:
84
+
85
+ $ notion-publish --whoami
86
+ Docs Publisher -- internal connection owned by the workspace
87
+ id: 1a2b3c4d-...
88
+ workspace: Acme
89
+ API version: 2026-03-11
90
+
91
+ When something cannot be found, Notion returns the same error whether it does not exist or is not
92
+ shared with the connection. `notion-publish` says so rather than guessing which one it is.
93
+
94
+ ## Choosing a destination
95
+
96
+ Every Notion page needs a parent. An internal connection cannot create pages at the top level of a
97
+ workspace, so there is no default for a new page. A file that `notion-publish-manifest.yml` already
98
+ tracks needs no destination: its page is updated wherever it is. Otherwise the destination comes
99
+ from the first of these that is set:
100
+
101
+ 1. `--parent` or `--database` on the command line
102
+ 2. `notion_parent:` or `notion_database:` in the file's front matter
103
+ 3. `parent:` or `database:` in `.notion-publish.yml`
104
+
105
+ `--parent` accepts three forms and works out which one it was given:
106
+
107
+ --parent 2efab123cd458061b273eac31bb95510
108
+ --parent 'https://app.notion.com/p/Some-Page-2efab123cd458061b273eac31bb95510?v=...'
109
+ --parent 'Policies'
110
+
111
+ A value that is only a 32-character ID or a dashed UUID is an ID. Anything that looks like a URL is
112
+ a URL. Anything else is a database name. The ID check is anchored, so `'Q3 Report 2efab123...'` is
113
+ treated as a name. A `?v=` view ID in a URL is ignored.
114
+
115
+ An ID or URL may name a page, a database, or a data source, and you do not have to say which. Page
116
+ IDs and database IDs look the same in a URL, so the tool asks Notion what the ID is.
117
+
118
+ Database names must match exactly, ignoring case. Notion's search is fuzzy and ranked by relevance,
119
+ so the tool never publishes into a near match. If there is no exact match, it lists the close ones
120
+ with their IDs. `--database` forces name lookup, for a database whose name happens to look like an
121
+ ID.
122
+
123
+ Since API version 2025-09-03, a Notion database is a container for one or more data sources, and the
124
+ schema and rows belong to the data source. A database with one data source resolves to it. A
125
+ database with two or more is reported as ambiguous, with the ID of each, so you can pick one.
126
+
127
+ ## Settings file
128
+
129
+ `.notion-publish.yml` holds settings that you write by hand. The tool reads it and never rewrites
130
+ it, so your comments survive.
131
+
132
+ ```yaml
133
+ # Everything under this directory goes to the Policies database.
134
+ database: Policies
135
+ icon: 🔒
136
+ ```
137
+
138
+ The keys are `parent`, `database`, `icon`, and `cover`. An unknown key is an error, so a typo does
139
+ not get ignored.
140
+
141
+ Every `.notion-publish.yml` from the repository root down to the document's own directory is read,
142
+ and the nearest one wins for each key. That works the same way as `.editorconfig`. The search stops
143
+ at the directory that contains `.git`.
144
+
145
+ ## Properties
146
+
147
+ When the destination is a database, the page's properties come from three layers. A later layer
148
+ replaces an earlier one for the same property name.
149
+
150
+ 1. Front matter, under a `properties:` key:
151
+
152
+ ```yaml
153
+ ---
154
+ properties:
155
+ Function: [Operations, Security and Compliance]
156
+ Status: Draft
157
+ Due Date: 2026-09-15
158
+ ---
159
+ ```
160
+
161
+ 2. `--properties-json '{"Function": ["Legal"], "Due Date": "2026-09-15"}'`
162
+
163
+ 3. `--property`, repeated for multi-valued properties:
164
+
165
+ --property 'Function=Operations' --property 'Function=Security and Compliance'
166
+
167
+ `--property` splits on the first `=`, so names may contain spaces and values may contain anything.
168
+ An empty value clears a property: `--property 'Owner='`.
169
+
170
+ To see what a destination accepts:
171
+
172
+ $ notion-publish properties --parent Policies
173
+ database "Policies" (2efab123-...)
174
+ Name title
175
+ Function multi_select
176
+ Operations | Security and Compliance | Legal
177
+ Owner people
178
+ Due Date date
179
+ Last edited by last_edited_by (read-only)
180
+
181
+ ### Validation
182
+
183
+ The schema is fetched before anything is written, so the syntax needs no type information and many
184
+ mistakes are caught early:
185
+
186
+ - An unknown `select`, `multi_select`, or `status` option is refused. Notion would create the option
187
+ rather than reject it, so a typo would quietly add to the schema.
188
+ - Giving two values to a single-valued property is an error, not "last one wins".
189
+ - A misspelled property name gets "did you mean" suggestions.
190
+ - Computed properties (`formula`, `rollup`, `last_edited_by`, and so on) are reported as owned by
191
+ Notion.
192
+ - `people` accepts a user ID or a person's exact name.
193
+ - `date` accepts `2026-09-15` or a range, `2026-09-15..2026-09-20`.
194
+ - `checkbox` accepts `true`/`false`, `yes`/`no`, or `1`/`0`.
195
+ - `relation` accepts page IDs or Notion URLs.
196
+ - `files` properties cannot be set.
197
+
198
+ ### Which properties the tool owns
199
+
200
+ Publishing is declarative over the properties the tool has set before. Each entry in
201
+ `notion-publish-manifest.yml` records the names of the properties it set. On the next publish:
202
+
203
+ | Property | Set in this run | Set by the tool before | Result |
204
+ |----------|-----------------|------------------------|------------|
205
+ | Function | yes | yes | updated |
206
+ | Owner | no | yes | cleared |
207
+ | Status | no | no | left alone |
208
+
209
+ A property the tool never set belongs to someone else. For example, a reviewer may change `Status`
210
+ in Notion, and publishing will not touch it.
211
+
212
+ ### Page parents
213
+
214
+ A page under another page has no properties, only a title. Front matter properties written for a
215
+ database are skipped with a warning, so the document still publishes. A `--property` flag given
216
+ explicitly is an error.
217
+
218
+ ## Title, icon, and cover
219
+
220
+ The title comes from `--title`, then `title:` in front matter, then the first `#` heading, then the
221
+ file name.
222
+
223
+ Notion uses a leading H1 as the page title, but only when it is the only H1 in the document. With a
224
+ second H1 anywhere, Notion keeps both, and the title appears twice. To make the result the same in
225
+ both cases, the leading H1 is removed from the body before sending. `--keep-h1` keeps it.
226
+
227
+ --icon 🔒
228
+ --icon ./logo.png
229
+ --icon https://example.com/logo.png
230
+ --cover ./banner.png
231
+ --cover https://example.com/banner.jpg
232
+
233
+ An icon may be an emoji, an image URL, or a path to a local image. A cover may be an image URL or a
234
+ local path. Local files are uploaded to Notion. Notion's own gallery covers are ordinary URLs, so
235
+ you can paste one. Icons and covers can also come from front matter (`notion_icon:`,
236
+ `notion_cover:`) or from `.notion-publish.yml` (`icon:`, `cover:`). The flag wins.
237
+
238
+ `--dry-run` checks both without uploading anything.
239
+
240
+ ## Republishing and the manifest
241
+
242
+ `notion-publish-manifest.yml` is the tool's record of which page each file became. The tool writes
243
+ it; you commit it. It lives at the repository root by default, and `--manifest` overrides that.
244
+
245
+ ```yaml
246
+ # Generated by notion-publish. Do not edit by hand.
247
+ # Maps each Markdown source to the Notion page it mirrors.
248
+ # To re-point an entry, delete it and publish again.
249
+ # Settings belong in .notion-publish.yml, which this tool never rewrites.
250
+ ---
251
+ workspace_id: 5b1c...
252
+ pages:
253
+ policies/access-control.md:
254
+ id: 3cfab123-cd45-818b-9a72-c2bd16e85a62
255
+ url: https://app.notion.com/p/Access-Control-Policy-3cfab123cd45818b9a72c2bd16e85a62
256
+ parent:
257
+ type: data_source_id
258
+ id: 2efab123-cd45-8088-a1d3-000b41c7f38c
259
+ name: Policies
260
+ properties:
261
+ - Function
262
+ - Name
263
+ - Owner
264
+ flag_properties:
265
+ - Owner
266
+ title_override: Access Control
267
+ source_sha256: 2b1f...
268
+ properties_sha256: 77ad...
269
+ flag_properties_sha256: 51e0...
270
+ notion_sha256: 9c04...
271
+ published_at: '2026-09-02T03:11:00Z'
272
+ ```
273
+
274
+ The file holds page IDs, URLs, and hashes. It holds no credentials or page content. Keys are sorted
275
+ so that publishing one document changes one entry in the diff.
276
+
277
+ | Field | Meaning |
278
+ |--------------------------|---------------------------------------------------------------------|
279
+ | `properties` | Every property the tool set, so it knows which ones it owns |
280
+ | `flag_properties` | The ones whose values came from `--property` or `--properties-json` |
281
+ | `title_override` | The `--title` given, if any |
282
+ | `keep_h1` | Present when `--keep-h1` was given |
283
+ | `source_sha256` | Hash of the Markdown file |
284
+ | `properties_sha256` | Hash of the property values that came from the document |
285
+ | `flag_properties_sha256` | Hash of the property values that came from flags |
286
+ | `notion_sha256` | Hash of the page as Notion returned it after publishing |
287
+
288
+ Flag-set property values are hashed but not stored. `republish` uses `flag_properties`,
289
+ `title_override`, and `keep_h1` to repeat what the last publish of each file did.
290
+
291
+ `--link` starts the file if it does not exist. Once it exists, every publish uses it. `--no-link`
292
+ ignores it and always creates a new page.
293
+
294
+ A publish with an entry does one of these:
295
+
296
+ | Output | What happened |
297
+ |--------------------------|-------------------------------------------------------|
298
+ | `Published ...` | No entry, or its page was deleted: a new page |
299
+ | `Unchanged ...` | The file, properties, and Notion page are as recorded |
300
+ | `Updated ...` | The file changed; body and properties were replaced |
301
+ | `Updated properties ...` | Only the properties changed; the body was left alone |
302
+ | An explanation, exit 3 | The page was edited in Notion; nothing was written |
303
+
304
+ Updating keeps the page's URL, so links to it keep working.
305
+
306
+ ### Edits made in Notion
307
+
308
+ After each publish the tool reads the page back and records a hash of what Notion returns. Every
309
+ later publish of that file reads the page again, even when the Markdown has not changed. If the
310
+ hash differs, someone edited the page in Notion, and the tool stops with exit code 3 instead of
311
+ reporting `Unchanged` or overwriting their change. Look at the page and decide which version is
312
+ right:
313
+
314
+ - To keep the edit, move it into the Markdown and publish with `--force`.
315
+ - To discard it, publish with `--force`. That puts the Markdown's version back.
316
+
317
+ If every page reports this at once, Notion probably changed how it renders Markdown. Nobody edited
318
+ them. `--force` is safe in that case.
319
+
320
+ ### Pages removed from Notion
321
+
322
+ Deleting a page in Notion moves it to the trash. The API still returns a trashed page, content and
323
+ all, and marks it `in_trash`. If an entry points at a page that is in the trash, or that no longer
324
+ exists at all, the tool warns, forgets the entry, and creates a new page. `--force` is not needed.
325
+
326
+ If you trashed the page by mistake and want to keep its URL, restore it from Notion's trash before
327
+ publishing again.
328
+
329
+ ### Child pages
330
+
331
+ Replacing a page body would delete any child page or database inside it. The tool refuses rather
332
+ than doing that.
333
+
334
+ ## Publishing a set of files
335
+
336
+ Each run publishes one file, so a set of files is a shell loop:
337
+
338
+ for f in policies/*.md; do
339
+ notion-publish "$f" --link
340
+ done
341
+ notion-publish relink policies
342
+
343
+ Put the destination and shared properties in `.notion-publish.yml`, or pass them in the loop. The
344
+ loop is safe to re-run. An unchanged file reports `Unchanged` after reading its page from Notion. A
345
+ failure part way through leaves the earlier files published and recorded, so running the loop again
346
+ picks up where it stopped. To stop at the first failure, add `|| break`.
347
+
348
+ `--property` is the highest layer, so a `--property` in the loop overrides the same property in
349
+ every file's front matter. If one document needs a different value, publish it separately or move
350
+ the property into front matter.
351
+
352
+ The manifest is locked while it is written, so parallel runs (`xargs -P`) do not lose entries.
353
+
354
+ Once the files are published, use `republish` to keep them current.
355
+
356
+ ## Republishing everything
357
+
358
+ `republish` updates every page the manifest tracks, without naming files:
359
+
360
+ notion-publish republish # every entry in the manifest
361
+ notion-publish republish policies # only entries for files under policies/
362
+
363
+ Each file goes through the same checks as a single publish. Files that were updated, recreated,
364
+ skipped, blocked, or failed are listed, and a summary line follows. `-v` also lists the files that
365
+ were unchanged.
366
+
367
+ 12 documents: 10 unchanged, 1 updated, 1 changed in Notion
368
+
369
+ ### What republish uses
370
+
371
+ Each page is updated where it already is, so no destination is needed. The recorded parent supplies
372
+ the schema. An entry made by `adopt` records no parent, so `republish` asks Notion where the page
373
+ is. For each file, `republish` uses:
374
+
375
+ - the file's current Markdown and front matter
376
+ - `icon` and `cover` from front matter or `.notion-publish.yml`
377
+ - the `--title` and `--keep-h1` recorded from the last single-file publish
378
+
379
+ Options that describe one document or one destination are ignored, with a warning. They are
380
+ `--parent`, `--database`, `--property`, `--properties-json`, `--title`, `--icon`, `--cover`,
381
+ `--keep-h1`, `--link`, `--no-link`, `--page`, `--yes`, `--local`, and `--untracked`. Changing a
382
+ destination never moves an existing page. `--no-upload`, `--force`, `--force-properties`, `--json`,
383
+ `--jobs`, `--manifest`, and `-v` apply to every file.
384
+
385
+ ### Properties set with flags
386
+
387
+ The values of properties set with `--property` or `--properties-json` are not recorded, so
388
+ `republish` cannot send them again. It leaves those properties alone. It does not set them and does
389
+ not clear them. Everything else follows the usual rules, so a property you add to or remove from
390
+ front matter is set or cleared.
391
+
392
+ To change a flag-set property, publish that file by name with the new flag. To hand a property over
393
+ to front matter, add it to front matter and publish the file once by name without the flag.
394
+
395
+ If `republish` has to recreate a page because the old one was deleted, the new page does not have
396
+ the flag-set properties. It warns and names them.
397
+
398
+ ### Entries without `flag_properties`
399
+
400
+ Entries written before `flag_properties` was added do not say which properties came from flags.
401
+ `republish` updates such an entry only if the file's front matter produces exactly the properties
402
+ recorded last time. Otherwise it skips the file, explains why, and exits with code 3. Publish that
403
+ file once by name, with whatever flags it needs. That records the new fields, and `republish`
404
+ handles it from then on.
405
+
406
+ ### Not included
407
+
408
+ - Files with no entry. Publishing a new document is something you do by name, once.
409
+ - Entries whose file is gone. They are reported, and their pages are left in Notion.
410
+ - `--dry-run`. Use `notion-publish status` to see what `republish` would do.
411
+
412
+ Links are rewritten as each page is written. Every tracked file already has a URL, so `republish`
413
+ does not need a `relink` pass.
414
+
415
+ A failure in one file is reported, and the rest still run. The exit code is 1 if anything failed, 3
416
+ if anything was blocked or skipped, and 0 otherwise. With `--json`, files that failed or whose
417
+ source is gone get an object too, with `action` set to `failed` or `orphaned`.
418
+
419
+ `--force` applies to every file. Use it with `republish` only when every page reported as changed in
420
+ Notion should be overwritten, for example after Notion changes how it renders Markdown.
421
+
422
+ ## Links between documents
423
+
424
+ A relative link such as `[Access Control](access-control.md)` means nothing in Notion. Worse, Notion
425
+ turns it into `https://access-control.md`, a link to a domain nobody owns. When a manifest is
426
+ in use, the tool rewrites each relative link to the Notion URL recorded for that file. Anchors
427
+ (`#section`) are kept.
428
+
429
+ A document that links to one not yet published cannot be rewritten on the first pass. The tool warns
430
+ about it. `relink` makes the second pass: it reads each recorded page, finds links in the
431
+ `https://<file>.md` form, and replaces them with the right URLs.
432
+
433
+ notion-publish relink # the whole repository
434
+ notion-publish relink policies # the manifest found from that directory
435
+
436
+ `relink` also lists entries whose source file is gone. It does not remove their pages, because the
437
+ tool cannot tell a deleted file from a renamed one.
438
+
439
+ ## Adopting pages that already exist
440
+
441
+ If a document is already in Notion, maybe published by hand or by another tool, publishing would
442
+ create a second copy. `adopt` records that the file and the existing page are the same document. It
443
+ changes neither one.
444
+
445
+ Name the page:
446
+
447
+ notion-publish adopt policies/access-control.md \
448
+ --page https://app.notion.com/p/Access-Control-Policy-3cfab123cd45818b9a72c2bd16e85a62
449
+
450
+ Or search by title in the destination the file would publish to:
451
+
452
+ $ notion-publish adopt policies/access-control.md --parent Policies
453
+
454
+ One page in "Policies" is titled "Access Control Policy":
455
+ https://app.notion.com/p/Access-Control-Policy-3cfab123cd45818b9a72c2bd16e85a62
456
+ last edited 2026-08-14 by Jane Doe
457
+
458
+ Adopting means the next publish will replace that page's contents.
459
+ Adopt it? [y/N]
460
+
461
+ Adopt stops and explains instead of guessing when:
462
+
463
+ - more than one page has the title (pass `--page` to choose)
464
+ - the file already has an entry (delete it from `notion-publish-manifest.yml` first to re-point it)
465
+ - no page has the title (publish instead)
466
+ - there is no terminal to ask, as in CI (pass `--yes`)
467
+
468
+ `--yes` accepts a title match without showing it. Use it only when you already know what will be
469
+ adopted.
470
+
471
+ Adopting does not publish. The next `notion-publish` of that file, or the next `republish`, updates
472
+ the page, and needs no destination. That run always writes, because adopting does not claim that the
473
+ page already matches the file.
474
+
475
+ Title matching is a separate command because it is a guess. Everything else the tool does is exact:
476
+ an ID resolves or it does not, and a hash matches or it does not. Keeping the guess out of `publish`
477
+ means it never happens during an unattended run.
478
+
479
+ ## Checking status
480
+
481
+ `status` compares every entry in the manifest with its file and its page, without writing
482
+ anything:
483
+
484
+ $ notion-publish status
485
+ /repo/notion-publish-manifest.yml -- 12 tracked
486
+
487
+ Changed locally (1)
488
+ policies/access-control.md
489
+
490
+ Changed in Notion (1)
491
+ policies/incident-response.md
492
+ https://app.notion.com/p/...
493
+
494
+ Never published (1): pass --untracked to list them
495
+
496
+ 10 in sync, 1 changed locally, 1 changed in Notion, 1 never published
497
+
498
+ | State | Meaning | Needs action |
499
+ |---------------------------|-----------------------------------------|--------------|
500
+ | in sync | Nothing to do | no |
501
+ | changed locally | The file changed since it was published | yes |
502
+ | changed in Notion | The page was edited in Notion | yes |
503
+ | changed in both | Both | yes |
504
+ | page is gone from Notion | The page no longer exists in Notion | yes |
505
+ | page is in Notion's trash | The page was deleted in Notion | yes |
506
+ | no source file | The file was deleted or moved | yes |
507
+ | never published | A Markdown file with no entry | no |
508
+
509
+ Documents are grouped by state, with anything that needs action first. Documents in sync are
510
+ counted in the summary and listed with `-v`. Files that were never published are counted but not
511
+ listed, since a repository often has many files that are not meant for Notion. `--untracked` lists
512
+ them.
513
+
514
+ It exits 3 if anything needs action, which makes it usable as a CI check. Checking against Notion
515
+ takes two requests per tracked page: one for the page, which says whether it is in the trash, and
516
+ one for its content. `--local` skips both and compares file hashes only. `--json` prints one object
517
+ per file, including files that were never published.
518
+
519
+ ## Local images
520
+
521
+ Notion's Markdown import only understands images at public URLs. A local path produces an empty
522
+ image block with no error, so a missing diagram looks like a successful publish.
523
+
524
+ `notion-publish` handles local images this way:
525
+
526
+ 1. Each local image that is alone on its line is replaced with a placeholder paragraph. An image
527
+ written under a list item stays inside that item.
528
+ 2. The image file is uploaded to Notion.
529
+ 3. The page is created or updated from the Markdown with placeholders.
530
+ 4. Each placeholder is found, nested or not, the image block is inserted after it, and the
531
+ placeholder is deleted.
532
+
533
+ The rest of the document still goes through Notion's own parser. Uploads happen before the page is
534
+ touched, so a missing or oversized file fails without leaving a half-published page.
535
+
536
+ Alt text becomes the caption. Images inside code fences are ignored. An image in the middle of a
537
+ paragraph cannot become a block, because Notion has no inline images, so it is left alone and
538
+ reported. Images at `https://` URLs are passed through and must stay publicly reachable.
539
+
540
+ `--no-upload` skips uploading and warns about each image it leaves broken.
541
+
542
+ ## What is changed before publishing
543
+
544
+ Notion parses CommonMark and GitHub-flavored Markdown well. Tables, nested lists, task lists, and
545
+ code fences all come through. There is one major exception, which the tool corrects.
546
+
547
+ **Soft line breaks.** In CommonMark, lines next to each other form one paragraph. Notion makes a
548
+ separate block of each line. A file wrapped at a fixed width would arrive double-spaced, and wrapped
549
+ list items would fall out of their list. The tool joins each paragraph, list item, and block quote
550
+ back into one line with spaces before sending. An explicit hard break (two trailing spaces, or a
551
+ trailing backslash) becomes `<br>`. An image alone on its line is never joined into the text above
552
+ it. Code fences and table rows are not touched.
553
+
554
+ The leading H1 is also removed, as described under [Title, icon, and cover](#title-icon-and-cover).
555
+
556
+ ## Scripting and exit codes
557
+
558
+ | Code | Meaning |
559
+ |------|---------------------------------------------|
560
+ | 0 | Published, updated, or nothing needed doing |
561
+ | 1 | Failed |
562
+ | 2 | Usage error |
563
+ | 3 | Stopped and needs a person (see below) |
564
+ | 130 | Interrupted |
565
+
566
+ Code 3 means a page was edited in Notion, `republish` skipped a file, or `status` found work to do.
567
+
568
+ `--json` prints one object per document on stdout. Messages go to stderr.
569
+
570
+ {"source":"policies/access-control.md","action":"updated","id":"3cfab123-...",
571
+ "url":"https://app.notion.com/p/...","parent":"2efab123-...","parent_name":"Policies"}
572
+
573
+ `action` is `created`, `updated`, `properties`, `unchanged`, or `blocked`. From `republish` it can
574
+ also be `skipped`, `failed`, or `orphaned`, and failed files carry an `error`. `republish --json`
575
+ prints no summary line.
576
+
577
+ The tool never waits for input when there is no terminal. Where it would ask a question, it fails
578
+ and names the flag that answers it.
579
+
580
+ ### Output and progress
581
+
582
+ `republish` and `status` report only the files where something happened, followed by a summary
583
+ line. Changes, blocked and skipped files, failures, and warnings are always printed. A single-file
584
+ publish always reports its file, including `Unchanged`.
585
+
586
+ `republish`, `status`, and `relink` check three pages at a time, which is about as fast as Notion's
587
+ rate limit allows. Results are still printed in file order. `--jobs` changes the number:
588
+
589
+ - `--jobs 1` checks one page at a time. Use it with `-vv` when you want a request log that reads in
590
+ order, or to rule concurrency out when something looks wrong.
591
+ - `--jobs 2` eases off when another integration or CI job uses the same connection, since Notion's
592
+ rate limit is per connection.
593
+ - Values above 3 are allowed, up to 10, but mostly turn into rate-limit retries.
594
+
595
+ In a terminal, these commands also show one progress line on stderr, such as:
596
+
597
+ Checking 12/38 policies/access-control.md
598
+
599
+ The count is of files finished, and the name is a file still being checked. The line is rewritten
600
+ in place and cleared when the run ends. It is shown only when stderr is a terminal, so CI logs and
601
+ pipes never see it. It is also off with `-vv`, `--json`, and `--no-progress`.
602
+
603
+ Each `-v` adds a level of detail:
604
+
605
+ | Flag | Adds |
606
+ |--------|--------------------------------------------------------------------------------|
607
+ | `-v` | Every file: `Unchanged` lines from `republish`, the in-sync list from `status` |
608
+ | `-vv` | Each API request, with status, timing, and retries |
609
+ | `-vvv` | Request and response bodies, shortened |
610
+
611
+ ### Seeing the requests
612
+
613
+ `-vv` logs each API request to stderr, with its status and how long it took, plus any retries. It
614
+ also names the connection and workspace the token belongs to, which settles "why can't it see my
615
+ page?" when there is more than one token around.
616
+
617
+ $ notion-publish status -vv
618
+ notion-publish: GET /v1/users/me -> 200 (212 ms)
619
+ notion-publish: authenticated as "Docs Publisher" in "Acme"
620
+ notion-publish: GET /v1/pages/3cfab123-cd45-818b-9a72-c2bd16e85a62 -> 200 (180 ms)
621
+ notion-publish: GET /v1/pages/3cfab123-cd45-818b-9a72-c2bd16e85a62/markdown -> 200 (240 ms)
622
+ ...
623
+
624
+ `-vvv` also prints each request and response body, shortened to 300 characters. The token is never
625
+ logged. Bodies do contain document content, so think before turning on `-vvv` in a CI job whose
626
+ logs are kept or shared.
627
+
628
+ ## How Notion treats Markdown
629
+
630
+ These results were checked against the live API. Pipe tables become table blocks. Two-space nesting
631
+ becomes nested list items. Task lists become to-do blocks. Fenced code keeps its language. `---`
632
+ becomes a divider. Bold, italic, strikethrough, and inline code survive. H5 and H6 become heading 4,
633
+ as documented.
634
+
635
+ A block quote spread over several lines becomes one quote block per line, because Notion does not
636
+ join continuation lines. The soft-wrap fix above handles this.
637
+
638
+ ## Known limits
639
+
640
+ - A new file has to be published once by name. `republish` covers only files already in the
641
+ manifest.
642
+ - An image inside a paragraph cannot become a block.
643
+ - Files over 20 MB need Notion's multi-part upload, which is not implemented.
644
+ - `files` properties cannot be set.
645
+ - Page verification cannot be set or read through the API.
646
+ - H5 and H6 become heading 4.
647
+ - A bare `.md` file name in running text is auto-linked by Notion to a domain nobody owns. Only
648
+ Markdown link targets are rewritten.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/notion_publish"
5
+
6
+ exit NotionPublish::CLI.run(ARGV)