diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 075a65f..20a230a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,8 +16,7 @@ File an issue at For a bug, include the command you ran, what you expected, what happened, and the output of `markfluence --version`. Re-running with `--debug` often shows the request that failed. Redact your site URL, username, and token if you'd rather -not share them — but say which Confluence flavor you're on (Cloud or Data -Center), since the API differs. +not share them. ## Development setup @@ -30,7 +29,8 @@ make build # produces ./bin/markfluence make test ``` -Run `make` with no target for the annotated list of rules. +Run `make` with no target for the annotated list of rules. `make check` is the +one to remember — see below. To exercise the binary against a real Confluence site, put a `.env` in the working directory — see [`.env.example`](.env.example) and the diff --git a/Makefile b/Makefile index d97583d..00d2ede 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ GOLANGCI_LINT = $(LOCALBIN)/golangci-lint-$(GOLANGCI_LINT_VERSION) COMPLETIONS_DIR ?= completions -.PHONY: help all build build-linux install completions lint vet fmt fmt-check test check regen-regressions clean +.PHONY: help all build build-linux install completions lint vet fmt fmt-check test check regen-regressions docs docs-check clean help: ## Show this help @echo "Available rules:" @@ -62,6 +62,7 @@ check: ## Run every check CI runs, in CI's order -- the pre-flight before calli @# lint would otherwise race to populate ./bin. $(MAKE) vet $(MAKE) fmt-check + $(MAKE) docs-check $(MAKE) test $(MAKE) build $(MAKE) lint @@ -69,6 +70,20 @@ check: ## Run every check CI runs, in CI's order -- the pre-flight before calli regen-regressions: ## Regenerate the converter regression goldens go test ./internal/convert -run TestRegression -update +docs: ## Regenerate docs/commands/ from each command's --help + go run ./tools/gendocs + +docs-check: ## Fail if docs/commands/ disagrees with the binary's --help + @# docs/commands/ is generated and checked in, so the command reference is + @# browsable on GitHub without installing anything. A checked-in copy of + @# help text is only safe if it cannot drift, which is what this is for -- + @# the same role fmt-check plays for formatting. + @tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ + go run ./tools/gendocs "$$tmp" >/dev/null 2>&1; \ + if ! diff -rq docs/commands "$$tmp" >/dev/null 2>&1; then \ + echo "docs/commands/ is out of date; run: make docs"; \ + diff -ru docs/commands "$$tmp" | head -40; exit 1; fi + clean: ## Remove build artifacts (bin/, dist/, completions/, ./markfluence) rm -rf $(LOCALBIN) dist $(COMPLETIONS_DIR) markfluence diff --git a/README.md b/README.md index 6b5a4af..aa03d2c 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,24 @@ Markdown-centric Confluence cli tool. Works with Claude, works with GitHub actions, works with you. +## Location of documentation + +This file covers installing, configuring, and running each command. The rest +lives beside it, because it is reference material rather than a read-through: + +| | | +|---|---| +| [README.md](README.md) (this file) | installation, configuration, usage | +| [docs/commands/](docs/commands/) | every command's `--help`, rendered — the same text `markfluence CMD --help` prints, generated from the binary | +| [docs/markdown_file.md](docs/markdown_file.md) | the page format: every frontmatter field, and what the converter does with each body construct | +| [docs/github-actions.md](docs/github-actions.md) | running markfluence in CI: a working workflow, credentials, and why a service account | +| [docs/root-model.md](docs/root-model.md) | the documentation root: how a tree of files maps to a tree of pages | +| [CONTRIBUTING.md](CONTRIBUTING.md) | how to contribute: development setup, what to run before opening a pr, commit conventions, how to file an issue, etc | +| [docs/confluence/](docs/confluence/) | what we established about Confluence by experiment — the API, storage format, scopes, and the traps that produce confident wrong answers | +| [docs/guarantees.md](docs/guarantees.md) | the properties markfluence holds itself to, each with an honest status | +| [docs/json-output.md](docs/json-output.md) | `--json` in detail: status verbs, what counts as a result, why the shapes are what they are | +| [schema/json-output/v1.json](schema/json-output/v1.json) | the `--json` schema itself, also printed by `markfluence schema` | + ## Which Confluence Ways to run Confluence and markfluence support for it: @@ -103,50 +121,31 @@ chmod 600 .env ``` markfluence warns when the `.env` it read is reachable by anyone but you *and* -contains `CONFLUENCE_TOKEN` — a `.env` holding only the URL and username is -nobody's secret, so its mode is left alone. The warning names the file, what is -wrong with its mode, and the `chmod` that fixes it. Under `--json` it is not -printed but carried in the output document's `warnings` array (and on the -stderr error object), because stderr in that mode is itself a JSON document. +contains `CONFLUENCE_TOKEN`. The warning names the file, what is wrong with its +mode, and the `chmod` that fixes it. When markfluence is run with `--json`, the +warning is not printed but carried in the output document's `warnings` array +(and on the stderr error object), because stderr in that mode is itself a JSON +document. (Optional): `alias mf=markfluence` ### Scoped tokens and service accounts -For a normal personal API token, you can leave `CONFLUENCE_CLOUD_ID` unset. +For a normal personal API token, leave `CONFLUENCE_CLOUD_ID` unset. -For a **scoped** API token for an Atlassian [service account][svcacct], you -need to set `CONFLUENCE_CLOUD_ID`. You would use a scoped API token for a -service account to publish from CI or other automated system. Scoped tokens are -rejected with a **401** against your site domain--markfluence must use +For a **scoped** API token — the kind an Atlassian [service account][svcacct] gets, +for publishing from CI — you must set `CONFLUENCE_CLOUD_ID`. Scoped tokens are +rejected with a **401** against your site domain, so markfluence must use Atlassian's `api.atlassian.com` gateway, and the cloud ID is required there. -`CONFLUENCE_URL` still holds the site URL: markfluence uses it to write correct -links into the pages it publishes. -To find your cloud ID, you can do this: +To find your cloud ID (it is not a secret): ```console $ curl -s https://your-org.atlassian.net/_edge/tenant_info {"cloudId":"d8febd08-5555-5555-5555-db37c2369ce5"} ``` -The cloud ID is not a secret. - -The scopes markfluence needs: - -| Used for | Commands | Scope | -| --- | --- | --- | -| Reading pages, and reading/writing page width | `create`, `update`, `fix`, `info`, `read`, `export`, `find` | `read:page:confluence` | -| Creating and updating pages, and setting page width | `create`, `update`, `fix` | `write:page:confluence` | -| Resolving a space key to an id | `create`, `find`, `search`, `children --space` | `read:space:confluence` | -| Looking up a folder (a folder can be a page's parent) | `create` | `read:folder:confluence` | -| CQL queries | `find`, `search` | `search:confluence` | -| Author names, and mention display names | `info`, `read`, `export`, `update`, `create` | `read:confluence-user` | -| Uploading image attachments | `create`, `update`, `attachment-upload` | `write:confluence-file` | -| Downloading attachments | `export`, `attachment-download` | `readonly:content.attachment:confluence` | -| Listing attachments and child pages/folders | `children`, `export`, `read`, `attachment-list`, `attachment-download` | `read:confluence-content.summary` | - -Copy-pasteable: +The scopes markfluence needs, copy-pasteable: ``` read:page:confluence @@ -161,360 +160,152 @@ read:confluence-content.summary ``` > [!NOTE] -> Scopes are fixed when a token is issued. A missing one needs a **new** token, -> not an edit to the existing one. - -**The mixture of naming styles is correct, not a copy-paste error.** Atlassian -has two scope vocabularies — *classic* (`read:confluence-user`) and *granular* -(`read:page:confluence`) — and they are granted independently: holding one does -**not** imply the other. markfluence talks to both API versions, and each -version accepts only one vocabulary, so the list above is genuinely mixed. +> Scopes are fixed when a token is issued. A missing scope requires a **new** +> token to be created. -A token granted the classic names alone fails with -`401 Unauthorized; scope does not match` on almost every command, which is what -makes this an easy list to get wrong. The measurements behind that are in -[docs/confluence/api.md](docs/confluence/api.md#scopes). -If you get a `401 Unauthorized; scope does not match` error, you need additional -scopes. - -A **403** (rather than the 401 above) means the opposite problem: the token is -scoped for the call, but the service account lacks Confluence permission on that -space or page. Grant the account access; a new token will not help. - -#### Checking which scopes a token actually has +> [!NOTE] +> **The mixture of naming styles is correct, not a copy-paste error.** Atlassian +> has two scope vocabularies — *classic* (`read:confluence-user`) and *granular* +> (`read:page:confluence`) — granted independently, so holding one does **not** +> imply the other. markfluence talks to both API versions and each accepts only +> one vocabulary. -Atlassian offers no way to introspect a token, but the scope gate runs before -routing and validation, so one request per scope tells you. Aim it at an id that -does not exist — it reads nothing and creates nothing: +Diagnosing a failure: -```console -$ CID=your-cloud-id -$ curl -s -o /dev/null -w '%{http_code}\n' -u "$CONFLUENCE_USERNAME:$CONFLUENCE_TOKEN" \ - "https://api.atlassian.com/ex/confluence/$CID/wiki/api/v2/pages/999999999999" -401 -``` - -- **401** — the scope is **missing**. -- **any other 4xx** (400/403/404/415) — the request got past the scope gate, so - the scope is **present**; it failed later for an unrelated reason. +| symptom | meaning | +|---|---| +| `401 Unauthorized; scope does not match` | a scope is **missing** — issue a new token | +| **403** | the token is scoped for the call, but the account lacks Confluence permission on that space or page — grant access; a new token will not help | -Swap the path for the one whose scope you want to test, using the table above to -map scope to route. +**[docs/confluence/api.md](docs/confluence/api.md#scopes) is the reference**: which +scope each API call needs and how that was established, why the list is mixed, +the three calls Atlassian no longer documents, and how to probe a token for the +scopes it actually holds (there is no introspection endpoint, but the scope gate +runs before routing, so one request per scope answers it). [svcacct]: https://support.atlassian.com/user-management/docs/understand-service-accounts/ ## Usage -General: - -```sh -markfluence --help -markfluence schema --help -``` - -Manipulating Confluence pages: - -```sh -markfluence create --help -markfluence update --help -markfluence fix --help -markfluence info --help -markfluence read --help -markfluence find --help -markfluence search --help -markfluence children --help -markfluence export --help -``` - -Validating markdown locally (no network, no credentials): - -```sh -markfluence check --help -``` - -Manipulating Confluence page attachments: - -```sh -markfluence attachment-list --help -markfluence attachment-upload --help -markfluence attachment-download --help -``` - -Every command that takes a page accepts three forms: a numeric page id, a -Confluence page URL, or a Markdown file whose frontmatter has a `page_id`. +Each command explains itself: **`markfluence COMMAND --help`** is the reference +for what it does, why, and how to invoke it. What follows is which command to +reach for. -### `create` - -``` -Usage: markfluence create FILE... [flags] -``` +**Publishing markdown to Confluence:** -Create new Confluence pages from Markdown files. +| | | +|---|---| +| [`create`](docs/commands/markfluence_create.md) | make new pages from files that have no `page_id` yet. Checks every file first, and creates nothing if any would fail | +| [`update`](docs/commands/markfluence_update.md) | republish files that already have a `page_id`. Skips a file that has not changed | +| [`check`](docs/commands/markfluence_check.md) | validate files with no network and no credentials — dead links, broken images, bad frontmatter | +| [`fix`](docs/commands/markfluence_fix.md) | reconcile a file's frontmatter *from* its live page, when the two have drifted | -The page title comes from frontmatter, or from `--title` (which overrides the -frontmatter and requires a single `FILE`). +**Getting things out of Confluence:** -Confluence space can be specified on the command line (`--space SPACE`) or -in the frontmatter. +| | | +|---|---| +| [`read`](docs/commands/markfluence_read.md) | one page as markdown on stdout, or as raw storage | +| [`export`](docs/commands/markfluence_export.md) | a page, a subtree, or a whole space to files, attachments included | +| [`info`](docs/commands/markfluence_info.md) | one page's metadata: space, parent, version, width, labels, authors | -Optional parent can be specified on the command line (`--parent PAGE_ID`) or -in the frontmatter. In the frontmatter, you can specify the page id or -the Markdown file. The parent may also be a **folder** — the Confluence Cloud -content type — in which case give its id the same way you would a page's. +**Finding pages:** -Page width defaults to `max`; set it with `--page-width narrow|wide|max` (which -overrides the frontmatter `page_width` and may apply across a batch). +| | | +|---|---| +| [`find`](docs/commands/markfluence_find.md) | resolve an exact title to ids. Sees archived pages and folders, which `search` cannot | +| [`search`](docs/commands/markfluence_search.md) | full text, for when you do not know the title. Takes raw CQL with `--cql` | +| [`children`](docs/commands/markfluence_children.md) | list what is under a page, a folder, or a space | -All files are checked first — if any would fail (a problem with its `page_id`, a -title clash in the space, an unresolvable parent, or markdown the converter -refuses), nothing is created. Both kinds of clash name the page in the way, so -you can go look at it: +**Attachments** — `create`/`update` handle a page's images for you; these are for +everything else: -```console -$ markfluence create docs/runbook.md - ✗ [docs/runbook.md] a page already exists at page_id 123 ("Deploy Runbook"): https://wiki.example.net/wiki/spaces/ENG/pages/123/Deploy+Runbook - ✗ Aborting: 1 file(s) failed preflight; nothing was created. -``` +| | | +|---|---| +| [`attachment-list`](docs/commands/markfluence_attachment-list.md) | what is attached to a page | +| [`attachment-upload`](docs/commands/markfluence_attachment-upload.md) | attach a file, skipping one whose checksum already matches | +| [`attachment-download`](docs/commands/markfluence_attachment-download.md) | fetch attachments back to the paths they were published from | -A file whose `page_id` doesn't resolve is also a failure, not a fresh page: -`create` will not publish a second copy and overwrite the id it can't explain. -Remove the `page_id` to create a new page, or correct it. A `page_id` that isn't a -numeric id at all (a pasted URL, a leftover placeholder) is reported as such -without asking Confluence about it. - -On success `title`, `space`, `parent`, `page_id`, and `page_width` are written -back into each file — unless `--no-persist` is given, in which case nothing is -written back (and the file won't record its new `page_id`). - -A whole tree can be created in one pass: give each child a `parent:` that points at -its parent's `.md` file, and `create` orders creation parents-first and fills in the -real ids (see the `parent` field below). Creation is three-phase — every file is -validated (above), then a content-less stub is reserved for each, parents-first, -before any of them is converted — so a link from one file in the batch to another -resolves regardless of which direction it points, or whether the two link to each -other. A run interrupted after this point leaves a permanent, empty page version -behind rather than no page at all; every id is already persisted (unless -`--no-persist`), so a plain `update` finishes the job. - -The preflight phase converts each file too, keeping only the answer to "can this -convert at all?" — so markdown the converter refuses (two images in one document -whose file names match, say) aborts the batch instead of leaving an empty page -and a `page_id` behind. A stub can still be left by a failure while publishing: -a server or network error, an attachment that turns out to be unreadable, or a -frontmatter file that can't be written. See S7 in -[docs/guarantees.md](docs/guarantees.md), which names all three. - -`--dry-run` checks every file (the same checks a real run makes, so it exits -non-zero on the same failures) and previews what would be created — pages, -attachment uploads, page widths, and frontmatter write-backs — without writing to -Confluence or to any file. Because it makes the same checks, one unpublishable -file aborts the preview for the whole batch rather than previewing the rest; to -lint several files independently, use [`check`](#check) instead. Because nothing is created, a previewed page has no id -or URL yet; an in-set child's `parent` is unresolved, but its source file is -reported in the `parent_file` output field (present in every run, in `--json`). +And [`schema`](docs/commands/markfluence_schema.md) prints the `--json` schema. -```sh -markfluence create docs/new_page.md --space ENG -markfluence create docs/child.md --space ENG --parent 123456 -markfluence create docs/*.md --space ENG # hierarchy via parent: paths -markfluence create note.md --space ENG --title "Ad-hoc note" --page-width wide -markfluence create note.md --space ENG --no-persist # create without touching the file -markfluence create docs/*.md --space ENG --dry-run # preview; write nothing -``` +Every command takes `--json`; `create`, `update` and `fix` take `--dry-run`. -### `update` +### Common workflows -``` -Usage: markfluence update FILE... [flags] -``` - -Update one or more Markdown files in Confluence. - -Page id and title are read from frontmatter. A `page_id` is **required** (from -frontmatter or `--page-id`); `update` errors if none is set. `--title` and -`--page-id` override the frontmatter and require a single `FILE`; `--title` -renames the page, and a title otherwise falls back to the live page's title. -Page width is asserted only when `--page-width` is passed or a `page_width` -frontmatter line is present — otherwise the live page's width is left untouched. -Labels work the same way: a `labels:` line is asserted exactly (anything on the -page that the file does not list is removed), and no `labels:` line means the -page's labels are left alone — not even read. `update` never writes back to the -file. - -A `page_id` that no longer resolves fails that file with what to do about it -(`page_id 999 not found (deleted or wrong); correct it, or remove it and -use create instead`), and one that isn't a numeric id at all is reported without -asking Confluence. Since `update` writes nothing back, fixing the id is always -safe: the file is exactly as you left it. - -Updates are skipped when a file hasn't changed since the page's last version -(compared by mtime) unless `--force` is given. Each file is processed -independently; the command exits non-zero if any file fails. - -`--dry-run` previews what would be published — the version bump, attachment -uploads, and any page-width change — without writing to Confluence. It honors the -mtime skip and `--force` just like a real run, so its forecast matches what a real -run would do. +Edit a page that already exists: ```sh -markfluence update docs/managing_an_incident.md -markfluence update docs/*.md --message "Bulk update" -markfluence update docs/foo.md --force # ignore the mtime check -markfluence update page.md --page-id 123456 # override the target page -markfluence update page.md --title "New Title" # override / rename -markfluence update docs/*.md --page-width wide # set width across a batch -markfluence update docs/*.md --dry-run # preview; write nothing -``` +# what is its page id? +markfluence find --space SRE "Deploy runbook" +# download it and all attachments +markfluence export 1234567890 -### `fix` +# ...edit the file it wrote... -``` -Usage: markfluence fix FILE... [flags] +# is this valid markdown? +markfluence check deploy-runbook.md +# publish changes to Confluence +markfluence update deploy-runbook.md ``` -Reconcile each file's frontmatter (`page_id`, `space`, `parent`, `page_width`, -`labels`, and a missing `title`) to match its live Confluence page. Labels are -reconciled even for a file with no `labels:` line, which is how you adopt a page -somebody labeled in the UI — the one place `fix` fills in a field `update` would -have left alone, because `fix` reconciles the file to the page rather than the -page to the file. The page is located by -`page_id`, or by searching for the `title` when `page_id` is absent. `fix` never -creates, updates, or moves pages — it's read-only on the server. It writes a file -when a field changed, and also when the frontmatter fields are out of canonical -order (`title`, `space`, `parent`, `page_id`, then the rest alphabetically), -which is reported separately as `reordered`. `--dry-run` reports both without -writing. +Create a new page: ```sh -markfluence fix docs/*.md -markfluence fix docs/foo.md --dry-run -``` +vi deploy_runbook.md -### `check` +# ...create the file... -``` -Usage: markfluence check FILE... [flags] -``` +# verify markdown is correct +markfluence check deploy-runbook.md +# create the page in the ENG space at the top level +markfluence create --space ENG deploy-runbook.md -Validate one or more Markdown files against the converter and frontmatter -rules — offline: no network access, no credentials, and no writes to -Confluence or to disk. This is the primitive a CI job, a pre-commit hook, or -an agent editing docs wants: validate every change instantly, with no risk of -publishing anything. Each file is processed independently; the command exits -non-zero if any file is broken or fails outright. - -It reports the same `Broken`/`Warnings` a real `update`/`create` would -produce — a missing or escaping image/link, an unpublished sibling link, a -`#fragment` matching no heading — each prefixed with the source line it came -from, plus four frontmatter checks: an unparseable/unterminated frontmatter -block, an invalid `page_width`, an invalid `labels` entry, and a -present-but-non-numeric `page_id`. -Deliberately not checked: whether `page_id`/`space`/`parent` are set at all — -`check` can't know whether you're about to `create` or `update`, and a false -positive there would be worse than a miss. A **Broken** result fails -(`update`/`create` would publish literal `LINK BROKEN: …`/`IMAGE BROKEN: …` -text); a **Warning** alone does not — an unpublished sibling link is the -normal state of a tree that hasn't been created yet, not a defect. - -`link not resolved: TARGET` — the most common warning — means `TARGET` is a -sibling `.md` file that exists under the documentation root but has no -`page_id` yet. A same-page anchor (`#heading`) hits this same warning when -the *current* file itself has no `page_id` yet, since it's internally -treated as a link to itself — which can read as though the file names -itself as missing; it doesn't, that's just this file before its first -publish (see [Links to sibling `.md` files](#body)). Other message shapes: -[`IMAGE BROKEN`/`LINK BROKEN`](#body), and `anchor not found: TARGET` for a -`#fragment` that matches no heading. +# ...make some edits... -```console -$ markfluence check docs/*.md - ✗ [docs/broken-links.md] line 12: LINK BROKEN: typo-target.md (not found) - [docs/guide.md] clean - ✗ 1 of 2 file(s) failed. +# verify markdown is correct +markfluence check deploy-runbook.md +# publish edits +markfluence update deploy-runbook.md ``` -`--show-html` additionally prints the converted storage HTML (indented by -nesting depth) and the attachment list, for debugging what a file would -actually publish without publishing it: +Export an entire tree of pages and edit them: ```sh -markfluence check docs/*.md -markfluence check --show-html docs/one-page.md -``` +# export an entire tree of pages and referenced attachments +markfluence export --depth all --dest docs 1234567890 -### `info` +# ...make edits... -``` -Usage: markfluence info PAGE [flags] +# verify markdown is correct +markfluence check docs/*.md docs/**/*.md +# update any pages that changed +markfluence update docs/*.md docs/**/*.md ``` -Print a page's metadata (id, title, status, space, parent, version, page width, -authors, dates, url). `PAGE` is a numeric page id, a Confluence page URL, or a -Markdown file whose frontmatter has a `page_id`. `--properties` also lists all of -the page's content properties. +Pick up changes somebody made in Confluence. This is the one command that +writes *to* your files *from* Confluence — every other one goes the other way: ```sh -markfluence info 1234567890 -markfluence info docs/foo.md --properties -``` +# what disagrees? nothing is written +markfluence fix docs/*.md --dry-run -### `read` - -``` -Usage: markfluence read PAGE [flags] -``` - -Fetch a Confluence page and print its body to stdout. `PAGE` is a numeric page id, -a Confluence page URL (the modern `/wiki/.../pages//...` form or a legacy -`?pageId=` URL), or a Markdown file whose frontmatter has a `page_id`. It -composes with shell redirection. - -`--format` selects the output: - -- `markdown` (**default**) — the page converted to GitHub-Flavored Markdown, with - `title`/`page_id`/`space`/`page_width`/`labels` frontmatter, i.e. a best-effort inverse of - what `create`/`update` publish. The Confluence API has no markdown - representation, so markfluence converts the storage body itself: constructs - markfluence emits round-trip faithfully, while editor-authored content degrades - gracefully — any macro markfluence doesn't map (panels, expand, status, …) and - column layouts pass through as raw storage tags, with a macro/cell body kept as - readable markdown, so they round-trip back through `create`/`update`. A page or - space link converts back to a markdown link, and so does a **mention** (see - below); an attachment link and a blog-post link stay as raw storage, since a - markdown link would republish to something else or nothing at all. Some other transforms are lossy (e.g. a table - cell background color outside the named swatches comes back as a literal hex), - so this is a reading aid, not a guaranteed source round-trip. -- `storage` — the page's raw storage-format XHTML, exactly as stored. - -```sh -markfluence read 1234567890 # markdown, with frontmatter -markfluence read 1234567890 > page.md -markfluence read 1234567890 --format storage > page.storage.xml -markfluence read "https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title" +# reconcile page_id, space, parent, page_width, labels and a missing title +markfluence fix docs/*.md ``` -### `children` +`fix` never creates, updates or moves pages — it is read-only on the server. +Note the asymmetry it settles: `update` leaves a field alone when your file does +not mention it, while `fix` fills that field in from the page. It is how you +adopt a page somebody labeled in the UI, or one you published by hand and want +a file for. -``` -Usage: markfluence children [PAGE] [flags] -``` +One thing it does *not* do: a `title` you have already set is left alone, so a +page renamed in Confluence does not rename your frontmatter. Only a missing or +blank `title` is filled in. -List the pages and folders under a page or folder. `PAGE` is a numeric id, a -Confluence page **or folder** URL, or a markdown file whose frontmatter has a -`page_id`. Pass `--space KEY` instead of a `PAGE` to list a whole space; exactly -one of the two is required. +### What the output looks like -```sh -markfluence children 1234567890 # direct children -markfluence children 1234567890 --depth 3 -markfluence children 1234567890 --depth all -markfluence children "https://org.atlassian.net/wiki/spaces/ENG/folder/1234567890" -markfluence children docs/index.md # children of the page index.md publishes to -markfluence children 1234567890 --json | jq -r '.results[] | select(.type=="page") | .id' -markfluence children --space ENG # the space's top level -markfluence children --space ENG --depth all # every page and folder in the space -markfluence children --space ENG --depth all --json | jq -r '.results[].title' -``` +Three commands whose shape is worth seeing before you run them. `children` +indents by depth, keeping `TYPE` and `ID` aligned so the output stays greppable: ``` TYPE ID TITLE @@ -523,127 +314,19 @@ page 1675427879 MozCloud planning page 1671692338 MozCloud observability focus and issues ``` -Titles indent by depth; `TYPE` and `ID` stay aligned so the output is still -greppable. Siblings appear in the order Confluence displays them, which takes a -merge — pages and folders come from separate requests. - -**Folders are listed, not just traversed.** A folder can hold the only pages in a -subtree, so listing pages alone would show nothing for a folder that contains -folders. A folder also counts as a level: at the default `--depth 1` a child -folder appears as a row, and `--depth 2` shows what is inside it. - -`--depth` takes a positive number or `all`, defaulting to `1`. `0` is rejected -rather than treated as "unlimited" — a common convention elsewhere, and silently -walking an entire space for someone who meant "none" is worse than an error. -`--depth all` is genuinely unbounded: it costs two requests per node, and the -default of `1` is what keeps the casual case cheap. - -Trashed pages and folders are not listed. Finding nothing is a success, not a -failure: the command prints `No children.` and exits 0, so `--json` reporting an -empty `results` array is how a script tests for an empty subtree. - -#### `--space` - -`--space` takes a space **key** (not a URL, and not a name), and lists that space -instead of a page: - -```sh -$ markfluence children --space AIM -TYPE ID TITLE -page 2097154 Africa Innovation Mradi Home -page 2097185 What is Africa Mradi? - - Showing the space's top level. Use --depth 2, or --depth all for the whole tree. -``` - -**Depth 1 is the space's top level**, which is usually just its homepage — hence -the reminder, which human output prints only when `--depth` was left at its -default, and prints to stderr so the table can still be piped. `--depth all` walks the whole space, at a pair of requests per page and -folder in it. - -A space's top level is its root **pages**: a folder created with no parent lands -under the homepage rather than at the root, so there is no such thing as a -root-level folder to miss ([docs/confluence/spaces.md](docs/confluence/spaces.md)). -More than one root page is normal, though — a page published with `parent: null` -is one — so this is not the same as listing the homepage's children. - -In `--json`, a row at the space root reports `"parent_id": null`, since it hangs -off no node and the space is not one. An unknown key is a usage error (exit 2), -not an empty result, exactly as it is for `find` and `search`. - -### `find` - -``` -Usage: markfluence find TITLE [flags] -``` - -Find the pages and folders whose title is `TITLE`. This is the one handle the -other commands cannot resolve: they take a page id, a page URL, or a Markdown -file with a `page_id`, and `find` is how you get from a title to one of those. -If you do not know the title, use [`search`](#search) instead. - -```sh -markfluence find "Deploy runbook" -markfluence find "Deploy runbook" --space ENG -markfluence find "Deploy runbook" --json | jq -r '.results[] | select(.type=="page") | .id' -``` +`find` reports current pages, archived pages and folders together, because all +three can hold the title you asked about but only one is a page you can publish +to: ``` TYPE ID SPACE STATUS TITLE URL -page 3277005 AVSE archived Deploy runbook https://org.atlassian.net/wiki/spaces/AVSE/pages/3277005/Deploy+runbook -page 5144768 CEX current Deploy runbook https://org.atlassian.net/wiki/spaces/CEX/pages/5144768/Deploy+runbook -folder 2950660103 CLOUDSERVICES current Deploy runbook https://org.atlassian.net/wiki/spaces/CLOUDSERVICES/folder/2950660103 +page 1675427879 ENG current Deploy runbook https://… +page 1293156436 CLOUDSERVICES archived Deploy runbook https://… ``` -The match is **exact and case-insensitive** — not a substring search. Results are -ordered by space, then type, then id. `--space` takes a space **key**, never a -numeric space id, the same rule frontmatter follows; an unknown key is an error -rather than an empty result, since a typo would otherwise be indistinguishable -from "no such page". - -**Archived pages are included, and marked.** An archived page does not appear in -the page tree, but it still holds its title — `create` in that space will be -refused until it is restored or renamed. That is the main reason to run `find` -before publishing, and why the `STATUS` column is not decoration. - -**Folders are included too, but they never explain a conflict.** A folder id is -a legitimate `parent`, so being able to look one up by name is useful. A folder -does not reserve a title: a page can be created with a folder's exact name in -the same space. So a folder row is somewhere to publish, never a reason you -cannot. - -Trashed pages are not listed. Finding nothing is a success: the command prints -`No matches found.` and exits 0, so `--json` reporting an empty `results` array -is how a script tests "does this title exist yet?" before deciding to create or -update. - -Answering takes two requests, because no single Confluence API can see all -three: the v2 pages route covers current and archived pages but cannot see a -folder, while CQL covers folders but cannot see an archived page. If either -request fails the whole command fails — half an answer here reads as "nothing -found", which is the one wrong answer that causes a duplicate. The details are -in [docs/confluence/search.md](docs/confluence/search.md). - -### `search` - -``` -Usage: markfluence search QUERY [flags] -``` - -Find pages by **full text**, for when you do not know the title. `find` answers -"does a page called *this* exist?"; `search` answers "where is the page about -deploys?". - -```sh -markfluence search "deploy runbook" -markfluence search "deploy runbook" --space ENG --limit 25 -markfluence search deploy --limit all --json | jq -r '.results[].id' -markfluence search 'type = page and label = "runbook"' --cql -``` - -A label search only finds pages someone labeled. Since markfluence publishes -labels from frontmatter (`labels:` above), a tree it manages is searchable this -way without anyone tagging pages in the UI. +`search` prints a block per hit rather than a row, because the excerpt is what +tells you *why* it matched — with the terms Confluence marked shown in reverse +video: ``` Deployment runbook @@ -651,236 +334,9 @@ Deployment runbook https://org.atlassian.net/wiki/spaces/PXI/pages/2064154670/Deployment+runbook Keep an eye on the deploy-packages job...to be deployed) -Runbook: Prod deployment - page 1293156436 CLOUDSERVICES - https://org.atlassian.net/wiki/spaces/CLOUDSERVICES/pages/1293156436/Runbook+Prod+deployment - "Deploy failed" message in #crash-ingestion-bots...look at logs in Github Actions - Showing 2 matches; more exist (use --limit all). ``` -Each hit is a block rather than a table row, because the excerpt is what tells -you *why* it matched, and an excerpt is too long for a column. - -**Matched terms in the excerpt are shown in reverse video**, using the positions -Confluence reports rather than by matching your query text — so the highlight -follows the server's own stemming (searching `deploy` marks `deploys`) and works -under `--cql`, where there are no query words to match against. Some hits come -back without them; Confluence marked 40 of 50 sampled rows. The highlight disappears -under `--no-color`, under `NO_COLOR`, and whenever output is piped or -redirected, so a captured excerpt is plain text. `--json` is unaffected: its -`excerpt` is the same plain string it has always been. - -**Multiple words are ANDed, and it is not a phrase search.** Every word must -appear somewhere in the page, in any order — `"deploy runbook"` and -`"runbook deploy"` return the same set. Adding a word narrows the search; -quoting does not require the words to be adjacent. - -**Results are in Confluence's relevance order, best first, and markfluence never -re-sorts them.** The API reports a relevance score of `0.0` on every row, so the -order it returns is the only ranking that exists — which is also why a `--json` -consumer should not sort `results`. - -**`--limit` defaults to 10, and never truncates silently.** A hit is a block of -5–6 lines rather than a row, so ten is about a screen. It takes a positive -number or `all`; `0` is refused rather than read as "unlimited", the same rule -`--depth` follows. When there are more matches than were shown, the command says -so. It reports *that* more exist rather than how many, because the API's own -total is an estimate that disagrees with what it returns. - -**`--type` defaults to `page`**, and also accepts `blogpost` or `all`. The search -index holds attachments, comments, databases and whiteboards too, and all of them -match text — but their ids are not something any other markfluence command -accepts, so they are behind `all`. `--type folder` is refused with a pointer to -`find`: a folder has no text, so it can never match a full-text query. - -**`--cql` passes QUERY straight through as [CQL](https://developer.atlassian.com/cloud/confluence/advanced-searching-using-cql/)**, -with no escaping and no clauses added. It cannot be combined with `--space` or -`--type`: those would have to be ANDed onto your query, which would regroup a -query containing `or` and silently answer something else. Put the clauses in the -query yourself. `--limit` still applies, since it bounds paging rather than the -query. - -**Two things `search` cannot find.** Archived pages are invisible to the search -index entirely, and so are folders. Both are what `find` is for. The index also -lags by up to about a minute, so a page created moments ago may not be there yet -— anything that has to be correct *now* should use `find`. - -Finding nothing is a success: the command prints `No matches found.` and exits 0. - -The evidence behind the query it builds — including why it uses `siteSearch` and -not the `text` field Atlassian documents — is in -[docs/confluence/search.md](docs/confluence/search.md). - -### `export` - -``` -Usage: markfluence export PAGE [flags] -``` - -Write a page and the attachments it uses to a directory — the one-command form -of `read` plus `attachment-download`. - -```console -$ markfluence export 1234567890 --dest ./out -wrote out/markfluence-test-page.md -downloaded out/assets/diagram.png - (skipped 2 unreferenced attachment(s); --all-attachments to include) -``` - -The page is written as Markdown with `title`/`space`/`parent`/`page_id`/ -`labels`/`page_width` frontmatter — byte-identical to what `read` prints — so an exported -file can be edited and published straight back with `update`. - -Attachments are written to the paths their images were published from, so the -exported tree matches the layout of the repo the page came from and previews -locally in GitHub or VSCode. `--depth` exports the page's descendants as well, mirroring the Confluence -hierarchy: - -```console -$ markfluence export 1234567890 --depth all --dest out -wrote out/markfluence.yaml -wrote out/handbook.md -wrote out/handbook/onboarding.md -downloaded out/handbook/onboarding/diagram.png -2 pages (2 exported, 0 skipped, 0 failed) -``` - -A page becomes `.md` with a `/` beside it holding its children and -its own Confluence-native attachments; a folder becomes a directory. Each -child's `parent:` points at its parent's file (`parent: ../handbook.md`), so the -tree can be published into fresh pages rather than only back into the ids it -came from. `--depth` takes `0` (the default, the page alone), a positive number, -or `all`. - -`--space KEY` exports a whole space instead of a page, its root pages forming -the top level. It needs an explicit `--depth`, since walking a space costs a -pair of requests per page and folder in it. A **folder** can be the target too, -in which case what is inside it becomes the top level. - -`markfluence.yaml` is written at `--dest` for a multi-page export, marking it as -a project root. Without it, each exported file's root would be its own -directory, and a shared asset above a page would resolve outside it — so the -tree would not publish back. An existing one is left alone. - -A page whose file already exists is skipped, so a re-run resumes rather than -re-fetching; `--force` re-exports everything, which is also how you refresh a -tree whose pages changed upstream. - -There is deliberately no `--attachments-dir`. It is no longer *unsafe* — an -attachment is named by its base name, so moving `assets/x.png` to -`attachments/x.png` keeps the name `x.png` and orphans nothing — but collecting -everything into one directory reintroduces exactly the collision the base name -already has to refuse: two pages' `diagram.png` cannot share a directory. - -Only attachments the page actually references are exported. That includes images, -attachment links, and references inside macros markfluence passes through -untouched. `--all-attachments` takes everything on the page instead; -`--skip-attachments` writes the page file only. - -`--file` names the page file, defaulting to a slug of the title -(`markfluence-test-page.md`), or the page id when the title slugs to nothing. -`--dest` defaults to the current directory and is created if missing. Existing -files are skipped unless `--force`, and `--dry-run` previews without writing. - -If the page references an attachment that isn't attached — already broken in -Confluence — the export still succeeds and reports it as a warning. - -Markdown is the only output format. Use `read --format storage` to inspect the -raw storage Confluence holds. - -### `attachment-list` - -``` -Usage: markfluence attachment-list PAGE [flags] -``` - -List a page's attachments. - -```console -$ markfluence attachment-list 1234567890 -NAME SIZE VER TYPE SOURCE -diagram.png 24.1 KB 3 image/png assets/diagram.png -notes.pdf 1.2 MB 1 application/pdf - -``` - -`NAME` is the name Confluence stores — for an image markfluence published, the -file's base name (see [Body](#body)) — and `SOURCE` is the Markdown image path -it came from, recorded in the attachment's comment. The table shows at a glance -which attachments a publish manages and which it will leave alone. - -`SOURCE` is a dash when no source path is recorded: either the attachment was -uploaded by hand, or it was published before markfluence recorded source paths. -Those two look the same here; `--json` has a `managed` field that tells them -apart. Attachments left behind by a naming change show up this way, which is how -you find them — including the percent-encoded names markfluence wrote before it -started naming attachments by their base name. - -### `attachment-upload` - -``` -Usage: markfluence attachment-upload PAGE FILE... [flags] -``` - -Upload or replace attachments on a page, complementing the automatic sync that -`create` and `update` perform for a page's images. - -Each file is attached under its path relative to the documentation root (its -base name, with no `markfluence.yaml` above it). A file whose contents already match -what's on the page is skipped, using the same checksum bookkeeping -`create`/`update` use, so uploading by hand and publishing agree on what's -current. `--force` uploads anyway (bumping the attachment's version), which is -how you repair an attachment whose stored bytes drifted while its checksum still -matches. `--dry-run` previews without writing. - -`--name` takes a **path**, not a name, for a single file — so `--name -assets/x.png` produces the attachment that an image written as -`![](assets/x.png)` resolves to: stored as `x.png`, with `assets/x.png` recorded -as its source. The stored name is always the base name of the recorded path, so -a later publish won't create a duplicate under a different one. - -Uploading several files at once refuses a collision the same way publishing -does: `arch/diagram.png` and `deploy/diagram.png` in one command both want the -attachment `diagram.png`, so neither is uploaded. - -```sh -markfluence attachment-upload 1234567890 diagram.png -markfluence attachment-upload 1234567890 report.pdf notes.txt -markfluence attachment-upload 1234567890 img.png --name assets/diagram.png -markfluence attachment-upload 1234567890 diagram.png --force -``` - -### `attachment-download` - -``` -Usage: markfluence attachment-download PAGE [NAME...] [flags] -``` - -Download a page's attachments. Each `NAME` is an attachment name as -`attachment-list` reports it; with no `NAME`, every attachment is downloaded. - -An attachment markfluence published is written back to the Markdown image path -recorded in its comment, so the downloaded tree matches what the page's Markdown -references and previews locally: - -```console -$ markfluence attachment-download 1234567890 --dest ./out -downloaded /out/assets/diagram.png -downloaded /out/notes.pdf -``` - -An attachment with no recorded path — one that originated in Confluence, or was -published before markfluence recorded them — is written under a directory named -after the page, since an attachment name is unique per page and not per space: -two pages' `diagram.png` would otherwise be one file. That is where `read` and -`export` point at it too. `--flat` writes everything directly under `--dest`, -under stored names. `--dest` defaults to the current directory and is -created if missing. An existing file is skipped unless `--force`, and -`--dry-run` previews without writing. - -A recorded path that would resolve outside `--dest` is refused for that -attachment: the path comes from an attachment comment, which anyone who can edit -the page controls. ### `--json` output @@ -935,76 +391,25 @@ the schema cannot drift from the implementation. The binary carries that same schema, so a consumer can fetch the contract without knowing anything about this repository (see [`schema`](#schema)). -Notes on the schema: - -- **Per-command stable.** Each command always emits the same keys in the same - shapes (empty values are `null` or `[]`); the key *set* differs per command. - `schema_version` is bumped on any breaking change. -- **`roots`** lists every distinct [documentation root](#the-documentation-root) - the command resolved, sorted — `[]` for a command with no per-file root - concept (`find`, `search`, ...) or a pre-flight failure that never reached - root resolution. `schema` emits no envelope at all, so it has no `roots` key - to speak of. -- **`warnings`** carries warnings about the *invocation* rather than about any - page or file — currently only the `.env` permission warning below. A result's - own warnings live on the result; this is for something that belongs to no - result. `[]` when there is nothing to report. It appears on the stderr error - object too, since a fatal failure emits no envelope and a credential failure - is exactly the run where a warning about your `.env` matters. -- **Status verbs** are per-command: `published`/`skipped` (`update`), - `created`/`not_created` (`create`), `changed`/`consistent` (`fix`), - `clean`/`warnings`/`broken` (`check`), - `created`/`updated`/`skipped` (`attachment-upload`), - `downloaded`/`skipped` (`attachment-download`), plus `failed`. `info`, `read`, - and `attachment-list` results carry data only (no status verb). -- **One result per target**, and the target is per-command: the page for - `info`/`read`/`export` (always one), the file for `update`/`create`/`fix`/`check`, - and the attachment for the three `attachment-*` commands — so - `.results[] | .filename` works and `summary.total` is the attachment count. - `export` nests the files it wrote in an `attachments` array on its page - result, the way `update`/`create` do. -- **`check`'s `broken` status is `ok: false` with no `error`/`code`** — unlike - every other failure, its `broken`/`warnings` arrays already say everything - there is to say, so there's no separate operational error to attach. Only - its `failed` status (a file that never reached the converter at all) sets - them, the same as every other command's failure. `check --show-html` adds a - `debug: { html, attachments } | null` field, populated only for a file that - reached the converter; `html` stays exactly what the converter produced - (unindented), since it's meant to match what `update`/`create` would - literally publish. -- **Compound values are objects**, never display strings — `version`, - `page_width`, and the `created`/`updated` author stamps on `info`. -- **`create`'s preflight abort** (any file failing means nothing is created) - lists every input file — failed ones with an `error`, the rest as - `not_created` — and sets `summary.aborted: true`. -- **Warnings and broken image/link notices** are data (`warnings`/`broken` - arrays on each result), not stderr log lines. -- **The discovery commands list what they found**, so `results` is one object per - match (`find`, `search`) or per node (`children`), and `summary.total` is that - count. `search`'s summary carries two extra fields: `truncated`, meaning - `--limit` was reached with matches left over, and `skipped`, counting index rows - that had no page id to report (reachable only via `--cql` or `--type all`). - Neither is a count of matches you could get by asking again for more. - -Errors and exit codes: - -- **Per-file operational failures** appear in `results` as - `{ "ok": false, "error": "…", "code": "…" }`; the command exits `1` if any - file failed. -- **`find` and `search` have no failed-result variant.** They name no page, so - there is no id to attach a failure to: an operational failure prints the same - typed error object to **stderr** and exits `1`, with no envelope on stdout. - Emitting an empty `results` array would be worse than emitting nothing, since - "no matches" is a meaningful answer that a caller acts on. -- **Fatal/pre-flight failures** (bad flags, credential resolution) print a typed - error object to **stderr** and exit `2`: - - ```json - { "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG", "warnings": [] } - ``` - -- Error `code` values: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`, - `IO`, `NETWORK`, `API`. +**[docs/json-output.md](docs/json-output.md)** covers the rest: the per-command +status verbs, what counts as one result for each command, `check`'s `broken` +status, `create`'s preflight abort, and why `find`/`search` report an +operational failure on stderr rather than as a result. + +Exit codes: + +| exit | meaning | +|---|---| +| `0` | success — including "no matches", which is an answer a caller acts on | +| `1` | a per-file or per-target failure; the envelope is still on stdout, with `ok: false` and an `error`/`code` on the failed results | +| `2` | a fatal pre-flight failure (bad flags, credential resolution). No envelope; a typed error object goes to **stderr** instead | + +```json +{ "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG", "warnings": [] } +``` + +Error `code` values: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`, +`IO`, `NETWORK`, `API`. ### `schema` @@ -1035,85 +440,10 @@ schema you get from a binary is the one its output was checked against. Nothing here talks to Confluence, so no credentials are needed. The output is already JSON; `--json` changes nothing. -## GitHub Actions - -markfluence can run in CI to keep Confluence pages in sync with markdown in -your repo: on a push to your default branch, publish the changed docs. You -will need to know the Confluence `page_id` for each page you want to update. - -### Credentials - -Store environment variables as [encrypted secret][secrets] (never commit them). -markfluence reads them straight from the environment — no `.env` in CI. - -- `CONFLUENCE_TOKEN` -- `CONFLUENCE_URL` -- `CONFLUENCE_USERNAME` - -Prefer a [service account][svcacct] over a personal token here, so published pages -aren't authored by an individual and publishing doesn't break when that person -rotates their token or moves on. That means a **scoped** token, which also needs -`CONFLUENCE_CLOUD_ID` (see [Scoped tokens and service -accounts](#scoped-tokens-and-service-accounts)). The cloud ID is not sensitive, so -make it a repository **variable** rather than a secret. - -[secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions - -### Workflow - -```yaml -name: Publish docs to Confluence - -on: - push: - branches: [main] - paths: ['docs/**.md'] # only when docs change - -# Avoid overlapping publishes racing on the same pages. -concurrency: - group: confluence-publish - cancel-in-progress: false - -jobs: - publish: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-go@v5 - with: - go-version: '1.25' - - # No release binaries are published yet, so install from source. Pin a tag - # (…@v1.2.3) once releases exist, rather than @latest, for reproducibility. - - name: Install markfluence - run: go install github.com/mozilla/markfluence@latest - - - name: Publish - env: - CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }} - CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }} - CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }} - # A variable, not a secret: the cloud ID is public. Omit it if you're - # using an unscoped personal token. - CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }} - run: - markfluence update --page-id=12345 --force docs/some_doc.md -``` - -Notes: - -- **Exit codes.** `update` exits non-zero if any file fails, so the job fails - loudly. Add `--json` to get machine-readable per-file results on stdout (see - [`--json` output](#--json-output)) if a later step needs to parse them. - -A reusable composite/Docker action wrapping this is tracked in -[#29](https://github.com/mozilla/markfluence/issues/29). - ## Markdown page structure -Each Markdown file is one Confluence page: an optional YAML **frontmatter** block -followed by the Markdown **body**. +Each Markdown file is one Confluence page: an optional YAML **frontmatter** +block followed by the Markdown **body**. ``` --- @@ -1128,320 +458,11 @@ page_width: max ... ``` -### Frontmatter - -Frontmatter is a **YAML** block delimited by `---` lines, restricted to flat -`key: value` pairs. A value is a single-line scalar, or a list of them — written -either inline (`labels: [a, b]`) or as `- ` lines. The fields markfluence reads -as single values (`title`, `space`, `parent`, `page_id`, `page_width`) are an -error when written as a list, rather than being read as unset. No nesting, and no multi-line -values. That restriction is enforced: a nested value, a `|` block, a duplicate -key, a tab indent, or a list item split over two lines is an error naming the -key, not something read as blank. Full-line `#` comments and trailing inline -` # ...` comments are preserved when markfluence rewrites a block, and a list -keeps whichever of the two spellings you wrote it in. - -Because it is real YAML, a value that YAML would read as something other than a -plain string has to be quoted — a colon-space (`title: "Deploy Runbook: Part 2"`), -a leading `#`, `[`, `{`, `@`, `*`, `&`, `%`, `!`, `|`, `>`, `-`, or `?`, leading -or trailing whitespace, and the words YAML types for you: `true`, `false`, `yes`, -`no`, `null`, `~`, and anything that looks like a number. **markfluence quotes -automatically whenever it writes a value**, so this only matters for frontmatter -you hand-write. - -`null` in any spelling (`null`, `Null`, `~`, or an empty value) means *unset*. -A page genuinely titled `null` is written `title: "null"`. - -| Field | Value domain | Notes | -| --- | --- | --- | -| `space` | a space key (e.g. `ENG`, or a personal space like `~1234abcd`) | Target space for `create` (or pass `--space`); written back by `create`. Always a key, never a numeric space id. | -| `parent` | `null`, a numeric page **or folder** id, or a relative `.md` path | `null` = top-level page; an id = an existing parent, which may be a page or a Cloud folder (the value is just an id either way — nothing records which kind it is); a `.md` path = a parent authored in the same run (`create` resolves it in dependency order, then rewrites the value to ` # `). Used by `create` (or `--parent`). | -| `page_id` | a numeric page id, or `null` | The target page. `update` looks it up by `title` and writes it back when missing; `create` writes it after creating the page. `null`/absent means "no page yet." | -| `title` | text (**required**) | The Confluence page title. | -| `labels` | a list of label names, e.g. `[ci/cd, howto]` | The page's labels. **Present means asserted exactly** — a label on the page that the file does not list is removed — and `labels: []` removes them all. **Absent means untouched**, so a page labeled by hand is safe from a run that never mentioned labels. Only `global:` labels are managed; a `my:`/`team:` label is shown by `info` and never written or removed — and if an unmanaged label shares a name with a surplus managed one, the removal is skipped with a warning, because Confluence's removal takes a name with no prefix and would delete the personal label instead. Names are lowercased (with a warning) since Confluence does that anyway; anything else invalid is an error before any write. `fix` writes back the live page's labels, which is how you adopt a page labeled in the UI. | -| `page_width` | `narrow`, `wide`, or `max` | The published page width (the UI's "Adjust width" options; `narrow`/`wide`/`max` map to the `default`/`full-width`/`max` appearance properties). Absent or blank defaults to `max`. `create`/`update` assert it on every publish (so a width set in the Confluence UI is overwritten unless the frontmatter matches); `fix` writes back the live page's width. | - -To create a page, you only need to specify the `title` in the frontmatter. - -### Mentions - -A Confluence mention round-trips as an ordinary markdown link to the person's -profile, with an `@` on the link text: - -```markdown -Ping [@Ada Lovelace](https://home.atlassian.com/people/712020:0e5f8a21-3c4d-4e5f-a6b7-c8d9e0f1a2b3) about the deploy. -``` - -`read` and `export` write that; `create` and `update` publish it back as a real -mention. Three things worth knowing: - -- **The `@` is what makes it a mention.** A link to the same URL whose text does - not start with `@` publishes as a plain link, so you can still link to - somebody's profile without pinging them. -- **The account id is the only durable part.** The display name is regenerated - on every `read`/`export`, so it goes stale harmlessly when somebody changes - their name, and the host is regenerated too — nothing site-specific survives - into the markdown. -- **An id that names nobody is a warning, not an error.** Confluence accepts any - account id and renders it as `@Unlicensed user` rather than failing, so - markfluence looks the id up and says so; nothing else will. - -**A colleague who has left keeps their name.** A deactivated account resolves -normally, and Confluence appends the suffix itself, so a round-tripped page -reads `[@Mark Reid (Deactivated)](…)` and records who has gone rather than -losing them. - -An id that genuinely does not resolve — a typo, a hand-edited URL — renders as -`[@Unlicensed user](…)`, matching what the page itself will show. The id stays -in the URL, so it is still the easiest thing to correct. - -If markfluence cannot *ask* whether an account exists (no network, a rejected -token), the mention is left exactly as it was rather than being given a -placeholder — otherwise one bad moment mid-export would write `Unlicensed user` -over every real name in a tree. - -### Body - -The body is [GitHub-Flavored Markdown](https://github.github.com/gfm/), converted -to Confluence storage format. Supported constructs: - -**Fenced code blocks** are rendered as Confluence code macros and support the -syntax highlighting, but only the languages Confluence supports. -[GFM fenced code](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) - -**Tables** use GFM syntax and are rendered as Confluence tables. -[GFM tables](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables) - -**Table cell background colors** can be specified using an HTML comment at the -start of the cell. They will be invisible in Markdown preview, but will have -the specified background color in Confluence. - -```markdown -| Service | Status | -| ------- | -------------------------- | -| auth | ok | -| billing | down | -``` - -The color is a swatch name from the Confluence editor's cell background palette, -or a literal `#rrggbb` hex for anything else. The 21 swatches, one row here per -column of the editor's picker: - -| Light | Medium | Bold | -| --- | --- | --- | -| `white` `#ffffff` | `light-grey` `light-gray` `#f4f5f7` | `grey` `gray` `#b3bac5` | -| `light-blue` `#deebff` | `blue` `#b3d4ff` | `bold-blue` `#4c9aff` | -| `light-teal` `#e6fcff` | `teal` `#b3f5ff` | `bold-teal` `#79e2f2` | -| `light-green` `#e3fcef` | `green` `#abf5d1` | `bold-green` `#57d9a3` | -| `light-yellow` `#fffae6` | `yellow` `#fff0b3` | `bold-yellow` `#ffc400` | -| `light-red` `#ffebe6` | `red` `#ffbdad` | `bold-red` `#ff8f73` | -| `light-purple` `#eae6ff` | `purple` `#c0b6f2` | `bold-purple` `#998dd9` | - -Details: - -- Confluence colors **cells**, not rows or columns; a colored column is - implemented with a marker per cell in the column and a colored row is - implemented with a marker per cell in the row. -- The marker works in header cells too. -- A cell holding nothing but a marker is an empty colored cell. -- The color marker has to be the first thing in the cell. Anywhere else it's - ignored with a warning, since a stray comment would otherwise do nothing - visible. -- An unknown color name is dropped with a warning and the cell publishes - uncolored. - -**Multi-line table cells** use a literal `
` to break a cell onto more than -one line. A real newline can't be used instead, since a GFM table row has to -stay on one physical line. - -```markdown -| Field | Notes | -| ----- | -------------------------- | -| Key | Type: string
JQL: "Key" | -``` - -Confluence's own editor represents a multi-line cell as separate paragraphs -rather than `
`; `read`/`export` converts that back to the `
` form -shown above, which is what publishes back to the same paragraphs. - -**Lists in table cells** use HTML list tags — `
    `, `
      `, and `
    1. ` — -directly in the cell, the same way `
      ` is used for a plain line break. -Markdown's own list syntax needs each item on its own line, which a table row -can't do, so it isn't an option here. - -```markdown -| Field | Values | -| ------ | ------------------------------------- | -| Status |
      • open
      • closed
      | -``` - -`read`/`export` recovers the same tags rather than converting them to -anything else. - -**GitHub alerts** — `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, -`[!CAUTION]` — become Confluence panels in the colour GitHub draws them in: - -| alert | colour | published as | -|---|---|---| -| `NOTE` | blue | `info` macro | -| `TIP` | green | `tip` macro | -| `IMPORTANT` | purple | ADF panel (no macro exists for purple) | -| `WARNING` | orange | `note` macro | -| `CAUTION` | red | `warning` macro | - -The mapping is one-to-one, so `read`/`export` recover the original alert. -[GFM alerts](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts) - -Example: - -```markdown -> [!NOTE] -> This is a note. -``` - -**Images** — `![alt](./path.png)` uploads a local file as an attachment (or -references a remote URL); a missing/unsupported image becomes -`line N: IMAGE BROKEN: …` text (`N` is the line it's on in the file). - -Image paths resolve relative to the Markdown file, the same way they do when you -view the file on GitHub, so a page in a subdirectory can share an asset -directory above it: - -``` -docs/ ← needs a markfluence.yaml here for this to work - assets/logo.png - guide/page.md → ![logo](../assets/logo.png) -``` - -That layout needs a [documentation root](#the-documentation-root) declared at -`docs/` — without one, each page's root defaults to its own directory, and -`guide/page.md` reaching above itself for `assets/` is out of bounds. - -> [!NOTE] -> An image path is a URL, not a filename, so a space or other special character -> has to be percent-encoded — `![shot](assets/my%20image.png)` for a file named -> `my image.png`. This is the same rule GitHub and your editor's preview follow, -> and it is what they produce when they write a link for you. -> -> The angle-bracket form `![shot]()` is an equivalent -> spelling of the same image. A bare space (`![shot](assets/my image.png)`) is -> not a valid path, so it is not an image at all and stays on the page as -> literal text — again matching what GitHub and your preview show. -> -> `markfluence read` and `markfluence export` write the encoded form, so a page -> round-trips back to Markdown that still renders. - -Every image is bounded by the [documentation root](#the-documentation-root): -one resolving outside it (`../../secrets/x.png`) is reported as -`line N: IMAGE BROKEN: … (outside the documentation root)` rather than -uploaded, and a symlink is refused even when it resolves inside the root. - -Confluence attachment names cannot contain `/`, so an image is attached under -its **base name**: `assets/logo.png` is attached as `logo.png`. The path — -relative to the root, not to the page — is recorded in the attachment's -comment, which is what `markfluence read` and `markfluence export` use to put -the file back where it came from. The same file referenced as -`../assets/logo.png` from a page one directory down is the same attachment, -since both resolve to the same root-relative path. - -Because the name is only the base name, two images in one file whose names -agree — `arch/diagram.png` and `deploy/diagram.png` — cannot both be published: -an attachment name is unique per page, so one would overwrite the other. That is -refused, naming both paths, and `markfluence check` reports it without -publishing. Rename one of the files. - -Extra properties ride in the title as JSON: - -```markdown -![alt](x.png '{"title":"…","width":"100","align":"center"}') -``` - -* `align` is left/center/right; -* `width`/`height` are pixels - -A plain title (`![alt](x.png "tooltip")`) becomes the image tooltip. - -Examples: - -``` -![alt text](./path.png) - -![alt text](https://example.com/image.png) - -![alt text](./path.png "title") - -![alt text](./path.png '{"title":"sometitle","width":100}') -``` - -**Links to sibling `.md` files** are rewritten to the target page's Confluence -URL; **heading anchors** are rewritten to Confluence's anchor scheme. - -As with image paths, a link destination is a URL: a sibling whose filename has a -space is written `[see](my%20doc.md)` (or `[see]()`), and a bare -`[see](my doc.md)` is not a link at all. The same applies to the fragment, so a -non-ASCII heading anchor may arrive as `#caf%C3%A9-section`. Both are decoded -before markfluence matches them against files and headings on disk, so either -spelling resolves. - -Whether an unresolved link is reported — and how badly — depends on why: - -* A target that **doesn't exist at all**, or **resolves outside the - documentation root**, is Broken: the whole link element is replaced with - literal `line N: LINK BROKEN: … (not found)` or - `line N: LINK BROKEN: … (outside the documentation root)` text, the same - way a broken image already is. -* A target that **exists but has no `page_id` yet** — the normal state of - every page in a tree that hasn't been published — is a Warning - (`link not resolved: …`); the href still renders exactly as written. A - **same-page anchor** (`#heading`) is internally treated as a link to the - *current* file, so it hits this exact warning too when the current file - itself has no `page_id` yet — which reads as though the file names itself - as missing; it doesn't, that's just this file before its first publish. -* A `#fragment` that **matches no heading** on an otherwise-resolvable target - is also a Warning (`anchor not found: …`); the link still works, it just - lands at the top of the page instead of the named heading. - -A mention, an attachment link, or an external URL was never meant to resolve -here and stays silent either way. - -**Comment directives:** -- `` — replaced with Confluence table-of-contents macro. -- `` — replaced with the build stamp, - `markfluence VERSION (SHA, DATE)` (the same string `markfluence --version` - prints). - -**Raw Confluence storage format.** You can paste Confluence -[storage format](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html) -markup (`` / `` elements — any macro, layout, etc.) straight from a -page's **⋯ → View storage format** into your markdown, and it's emitted verbatim. -Two conventions: - -- **Leave a blank line** between an `ac:`/`ri:` tag and any markdown you want - converted (e.g. a macro or layout-cell body). With a blank line the content is - parsed as markdown; tight against the tags it passes through literally. -- **Put the opening tag on its own line** (or self-close it) so it isn't wrapped in - a paragraph. - -For example, a two-column layout with markdown in each cell: - -``` - - - - -Left column with **markdown**. - - - - -Right column. - - - - -``` - -Storage markup shown inside a fenced code block stays literal (it isn't activated). +**[docs/markdown_file.md](docs/markdown_file.md) is the page-format reference**: +every frontmatter field and what each verb does with it, and every body +construct — tables and their cell conventions, GitHub alerts, images and how +their paths resolve, links between pages, mentions, and pasted Confluence +storage markup. ## The documentation root @@ -1455,7 +476,7 @@ Storage markup shown inside a fenced code block stays literal (it isn't activate `markfluence.yaml` at the root of that tree. Without one, each file's root still defaults to its own directory, which means a page can't reach an image or another page sitting *above* itself; a shared-assets layout like - the one in [Images](#body) above needs a declared root to work at all. + the one in [docs/markdown_file.md](docs/markdown_file.md) needs a declared root to work at all. ```yaml # Marks the root of a markfluence project. Image and link paths are recorded @@ -1477,7 +498,7 @@ For the reasoning behind this model — why a bare marker file, what it fixes, what it costs — see [docs/root-model.md](docs/root-model.md) and [_plans/025_file-organization.md](_plans/025_file-organization.md). -### Common tasks +### Moving files and assets **Moving or renaming a markdown file.** Just move it. Links to it resolve by where it actually is, via the root-relative link index — nothing elsewhere @@ -1497,30 +518,6 @@ same way: every page referencing it records a new attachment name on its next publish. Identity follows the asset's location, not any particular page's (this is L3 in [docs/guarantees.md](docs/guarantees.md) — `identity-from-asset-location`). -**Setting up a shared assets directory across many pages** needs a -`markfluence.yaml` at the directory that should be the shared root. Without -one, each page's root defaults to its own directory, and an asset above any -one of them is `IMAGE BROKEN` — the layout in [Images](#body) above needs -this to work at all. - -## Development - -Requires Go 1.25+. - -```sh -make build # build ./bin/markfluence -make test # go test ./... -make check # everything CI runs, in CI's order -- the pre-flight before a PR -``` - -Run `make` with no target for the full list of rules. Run the built binary -against Confluence by putting a `.env` in the working directory (see -[Configure](#configure)). - -See [CONTRIBUTING.md](CONTRIBUTING.md) for filing issues, the converter's -golden-file regression suite, commit conventions, and what to run before opening -a pull request. - ## Inspirations [pchuri/confluence-cli](https://github.com/pchuri/confluence-cli) -- command diff --git a/cmd/attachmentdownload/attachmentdownload.go b/cmd/attachmentdownload/attachmentdownload.go index 1362203..cc27787 100644 --- a/cmd/attachmentdownload/attachmentdownload.go +++ b/cmd/attachmentdownload/attachmentdownload.go @@ -51,6 +51,12 @@ var Cmd = &cobra.Command{ "attachment, since the path comes from an attachment comment anyone who\n" + "can edit the page controls.\n\n" + "A file that already exists is skipped unless --force.", + Example: " # Every attachment, to the paths they were published from\n" + + " markfluence attachment-download 1234567890 --dest ./out\n\n" + + " # Just one, by its stored name\n" + + " markfluence attachment-download 1234567890 diagram.png --dest ./out\n\n" + + " # Ignore recorded paths and write everything flat\n" + + " markfluence attachment-download 1234567890 --dest ./out --flat\n", Args: cobra.MinimumNArgs(1), ValidArgsFunction: completion.PageThenNames, RunE: run, diff --git a/cmd/attachmentlist/attachmentlist.go b/cmd/attachmentlist/attachmentlist.go index 1684a89..b47c238 100644 --- a/cmd/attachmentlist/attachmentlist.go +++ b/cmd/attachmentlist/attachmentlist.go @@ -33,6 +33,10 @@ var Cmd = &cobra.Command{ "SOURCE is a dash when no source path is recorded: the attachment was\n" + "uploaded by hand, or it was published before markfluence recorded one.\n" + "Use --json, whose managed field tells those two apart.", + Example: " # Every attachment on a page\n" + + " markfluence attachment-list 1234567890\n\n" + + " # By the file that publishes to it\n" + + " markfluence attachment-list docs/foo.md\n", Args: cobra.ExactArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/attachmentupload/attachmentupload.go b/cmd/attachmentupload/attachmentupload.go index 7c72a7e..bbec439 100644 --- a/cmd/attachmentupload/attachmentupload.go +++ b/cmd/attachmentupload/attachmentupload.go @@ -47,6 +47,13 @@ var Cmd = &cobra.Command{ "Two files whose base names agree cannot both be uploaded to one page,\n" + "since an attachment name is unique per page; that is refused rather\n" + "than silently overwriting.", + Example: " # Upload one file, or several\n" + + " markfluence attachment-upload 1234567890 diagram.png\n" + + " markfluence attachment-upload 1234567890 report.pdf notes.txt\n\n" + + " # Store it under the path a markdown image would reference\n" + + " markfluence attachment-upload 1234567890 img.png --name assets/diagram.png\n\n" + + " # Re-upload even though the checksum matches\n" + + " markfluence attachment-upload 1234567890 diagram.png --force\n", Args: cobra.MinimumNArgs(2), ValidArgsFunction: completion.PageThenFiles, RunE: run, diff --git a/cmd/check/check.go b/cmd/check/check.go index ed297ab..6fc29e7 100644 --- a/cmd/check/check.go +++ b/cmd/check/check.go @@ -55,6 +55,10 @@ var Cmd = &cobra.Command{ "resolves to a real heading in the current file, but can't be turned into\n" + "an absolute URL until this file itself has a page_id -- resolved by this\n" + "file's own first publish, nothing to fix.", + Example: " # Validate a batch of files\n" + + " markfluence check docs/*.md\n\n" + + " # Show the storage HTML a publish would send\n" + + " markfluence check --show-html docs/one-page.md\n", Args: cobra.MinimumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/children/children.go b/cmd/children/children.go index 1f5e20b..fbccd2c 100644 --- a/cmd/children/children.go +++ b/cmd/children/children.go @@ -44,6 +44,19 @@ var Cmd = &cobra.Command{ "would show nothing for a folder that contains folders.\n\n" + "A folder counts as a level: at the default --depth 1 a child folder\n" + "appears as a row, and --depth 2 shows what is inside it.", + Example: " # Direct children of a page\n" + + " markfluence children 1234567890\n\n" + + " # Deeper, or the whole subtree\n" + + " markfluence children 1234567890 --depth 3\n" + + " markfluence children 1234567890 --depth all\n\n" + + " # By folder URL, or by the file that publishes to a page\n" + + " markfluence children \"https://org.atlassian.net/wiki/spaces/ENG/folder/1234567890\"\n" + + " markfluence children docs/index.md\n\n" + + " # A whole space, and every page and folder in it\n" + + " markfluence children --space ENG\n" + + " markfluence children --space ENG --depth all\n\n" + + " # Just the page ids\n" + + " markfluence children 1234567890 --json | jq -r '.results[] | select(.type==\"page\") | .id'\n", Args: cobra.MaximumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/create/create.go b/cmd/create/create.go index c8ba130..8482ec5 100644 --- a/cmd/create/create.go +++ b/cmd/create/create.go @@ -56,15 +56,44 @@ var Cmd = &cobra.Command{ Use: "create FILE...", Short: "Create new Confluence pages from markdown files", Long: "Create new Confluence pages from markdown FILEs.\n\n" + + "The title comes from frontmatter, or from --title, which overrides it and\n" + + "requires a single FILE. The space comes from --space or frontmatter. The\n" + + "parent comes from --parent or frontmatter and may be a page or a Cloud\n" + + "folder -- give a folder's id the same way you would a page's. Page width\n" + + "defaults to max.\n\n" + "Every file is checked first -- including converting it -- and if any would\n" + - "fail, nothing is created.\n" + - "Otherwise a content-less stub is reserved for each, parents-first, before\n" + - "any of them is converted -- so a link between two files in the same batch\n" + - "resolves regardless of which direction it points, or whether the two link\n" + - "to each other. A parent cycle among the given files is rejected instead.\n" + - "--title and --page-width override the frontmatter (--title requires\n" + - "a single FILE). Unless --no-persist is given, each created page's\n" + - "title/space/parent/page_id/page_width are written back into the frontmatter.", + "fail, nothing is created. A page_id that resolves to nothing is a failure\n" + + "too, not a fresh page: create will not publish a second copy and overwrite\n" + + "an id it cannot explain. Remove the page_id to create a new page, or\n" + + "correct it.\n\n" + + "Once every file passes, a content-less stub is reserved for each,\n" + + "parents-first, before any of them is converted -- so a link between two\n" + + "files in the same batch resolves regardless of which direction it points,\n" + + "or whether the two link to each other. A parent cycle among the given\n" + + "files is rejected instead. A run interrupted after the reserve phase\n" + + "leaves an empty page version behind rather than no page; every id is\n" + + "already written back, so a plain update finishes the job.\n\n" + + "A whole tree can be created in one pass: give each child a parent: that\n" + + "points at its parent's .md file, and creation is ordered parents-first\n" + + "with the real ids filled in.\n\n" + + "Unless --no-persist is given, each created page's\n" + + "title/space/parent/page_id/page_width/labels are written back into the\n" + + "frontmatter.\n\n" + + "--dry-run makes the same checks as a real run, so it exits non-zero on the\n" + + "same failures and one unpublishable file aborts the preview for the whole\n" + + "batch. To lint several files independently, use check instead.", + Example: " # Create one page in a space\n" + + " markfluence create docs/new_page.md --space ENG\n\n" + + " # Create it under an existing parent page or folder\n" + + " markfluence create docs/child.md --space ENG --parent 123456\n\n" + + " # Create a whole tree, hierarchy taken from each file's parent: path\n" + + " markfluence create docs/*.md --space ENG\n\n" + + " # Override the title and width for a single file\n" + + " markfluence create note.md --space ENG --title \"Ad-hoc note\" --page-width wide\n\n" + + " # Create without writing page_id back into the file\n" + + " markfluence create note.md --space ENG --no-persist\n\n" + + " # Preview everything, write nothing\n" + + " markfluence create docs/*.md --space ENG --dry-run", Args: cobra.MinimumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/export/export.go b/cmd/export/export.go index 5c6c35c..3d01a40 100644 --- a/cmd/export/export.go +++ b/cmd/export/export.go @@ -76,6 +76,14 @@ var Cmd = &cobra.Command{ "and not per space. Only attachments the page references are exported;\n" + "--all-attachments takes everything on the page.\n\n" + "This is the one-command form of `read` plus `attachment-download`.", + Example: " # One page and the attachments it uses\n" + + " markfluence export 1234567890 --dest ./out\n\n" + + " # The page and its whole subtree, hierarchy mirrored on disk\n" + + " markfluence export 1234567890 --depth all --dest out\n\n" + + " # A whole space; --depth is required for a space walk\n" + + " markfluence export --space ENG --depth all --dest out\n\n" + + " # Re-export a tree whose pages changed upstream\n" + + " markfluence export 1234567890 --depth all --dest out --force\n", Args: cobra.MaximumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/find/find.go b/cmd/find/find.go index 97db3c1..5ba70d5 100644 --- a/cmd/find/find.go +++ b/cmd/find/find.go @@ -33,6 +33,12 @@ var Cmd = &cobra.Command{ "A folder does not reserve a title, so a folder hit is never a reason\n" + "a page cannot be created -- it is there to be found, not to warn.\n\n" + "Finding nothing is a success: the command says so and exits 0.", + Example: " # Every page, archived page and folder with this exact title\n" + + " markfluence find \"Deploy runbook\"\n\n" + + " # Scoped to one space\n" + + " markfluence find \"Deploy runbook\" --space ENG\n\n" + + " # Just the current page ids\n" + + " markfluence find \"Deploy runbook\" --json | jq -r '.results[] | select(.type==\"page\") | .id'\n", Args: cobra.ExactArgs(1), // Nothing here is completable: a title is free text and a space key lives // on the server, which completion may not go ask for. diff --git a/cmd/fix/fix.go b/cmd/fix/fix.go index e0fd0d7..5fb836e 100644 --- a/cmd/fix/fix.go +++ b/cmd/fix/fix.go @@ -27,14 +27,28 @@ var Cmd = &cobra.Command{ Use: "fix FILE...", Short: "Reconcile each markdown file's frontmatter to its live Confluence page", Long: "Reconcile each markdown file's frontmatter to its live Confluence page.\n\n" + - "Populates/refreshes page_id, space, parent, and page_width (and fills a\n" + - "missing title) from the live page. Each file is processed independently;\n" + - "the command exits non-zero if any file failed.\n\n" + + "Populates/refreshes page_id, space, parent, page_width and labels (and\n" + + "fills a missing title) from the live page. The page is located by page_id,\n" + + "or by searching for the title when page_id is absent. fix never creates,\n" + + "updates or moves pages -- it is read-only on the server. Each file is\n" + + "processed independently; the command exits non-zero if any file failed.\n\n" + + "It writes a file when a field changed, and also when the frontmatter keys\n" + + "are out of canonical order (title, space, parent, page_id, then the rest\n" + + "alphabetically), which is reported separately as reordered. --dry-run\n" + + "reports both without writing.\n\n" + + "Labels are reconciled even for a file with no labels: line, which is how\n" + + "you adopt a page somebody labeled in the UI. That is the one place fix\n" + + "fills in a field update would have left alone, because fix reconciles the\n" + + "file to the page rather than the page to the file.\n\n" + "parent is written as the live page's parent id. In a tree written by\n" + "`export --depth`, where parent points at the parent's own .md file,\n" + "fix therefore replaces that path with an id -- consistent with\n" + "reconciling to the live page, and worth knowing before running it over\n" + "an exported tree.", + Example: " # Reconcile a batch of files to their live pages\n" + + " markfluence fix docs/*.md\n\n" + + " # Report what would change, write nothing\n" + + " markfluence fix docs/foo.md --dry-run", Args: cobra.MinimumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/info/info.go b/cmd/info/info.go index 1589b14..c62c376 100644 --- a/cmd/info/info.go +++ b/cmd/info/info.go @@ -29,8 +29,17 @@ var Cmd = &cobra.Command{ Use: "info PAGE", Short: "Print metadata about a Confluence page", Long: "Print metadata about a Confluence page.\n\n" + + "Id, title, status, space, parent, version, page width, labels, the\n" + + "created/updated author stamps, and the page URL. An empty field is\n" + + "omitted rather than printed blank.\n\n" + "PAGE is a numeric page id, a Confluence page URL, or a markdown file\n" + - "whose frontmatter has a page_id.", + "whose frontmatter has a page_id.\n\n" + + "--properties also lists every one of the page's content properties, which\n" + + "is where Confluence keeps things like the page width.", + Example: " # By page id\n" + + " markfluence info 1234567890\n\n" + + " # By the file that publishes to it, with content properties\n" + + " markfluence info docs/foo.md --properties", Args: cobra.ExactArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/read/read.go b/cmd/read/read.go index a9835ed..da903f6 100644 --- a/cmd/read/read.go +++ b/cmd/read/read.go @@ -33,8 +33,27 @@ var Cmd = &cobra.Command{ "PAGE is a numeric page id, a Confluence page URL (the modern\n" + "/wiki/.../pages//... form or a legacy ?pageId= URL), or a\n" + "markdown file whose frontmatter has a page_id.\n\n" + - "The default markdown output carries title/space/parent/page_id/page_width\n" + - "frontmatter and is a best-effort inverse of what create/update publish.", + "It composes with shell redirection.\n\n" + + "--format markdown (the default) carries\n" + + "title/space/parent/page_id/labels/page_width frontmatter and is a\n" + + "best-effort inverse of what create/update publish. The Confluence API has\n" + + "no markdown representation, so the storage body is converted here:\n" + + "constructs markfluence emits round-trip faithfully, while editor-authored\n" + + "content degrades gracefully -- a macro markfluence does not map, and a\n" + + "column layout, pass through as raw storage tags with their bodies kept as\n" + + "readable markdown, so they publish back unchanged. Some transforms are\n" + + "lossy (a table cell colour outside the named swatches comes back as a\n" + + "literal hex), so this is a reading aid rather than a guaranteed source\n" + + "round-trip.\n\n" + + "--format storage prints the raw storage-format XHTML exactly as stored.", + Example: " # Markdown, with frontmatter, to stdout\n" + + " markfluence read 1234567890\n\n" + + " # Save it as a file you can edit and publish back\n" + + " markfluence read 1234567890 > page.md\n\n" + + " # The raw storage Confluence holds\n" + + " markfluence read 1234567890 --format storage > page.storage.xml\n\n" + + " # By URL\n" + + " markfluence read \"https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title\"", Args: cobra.ExactArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/cmd/root.go b/cmd/root.go index cf6f115..6e47ba0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -99,6 +99,15 @@ func reportSecurityWarning(msg string) { // carried code (1 operational, 2 config/usage). Any other error is // cobra-generated (bad args/flags): a usage error, printed as a human line or a // JSON error object under --json, exiting 2. +// Root returns the root command, for tooling that needs to walk the command +// tree rather than run it -- currently only the docs generator, which renders +// every command's --help into docs/commands/. +// +// Exported for that one caller rather than left unexported with the generator +// living inside this package: a main() in here would be built into the binary, +// and a _test.go that writes files into the repo is not a test. +func Root() *cobra.Command { return rootCmd } + func Execute() { // Detect --json before parsing so that even a flag-parse failure (which // short-circuits PersistentPreRunE, where SetJSON normally runs) is reported diff --git a/cmd/root_test.go b/cmd/root_test.go index 1b33ef6..6316056 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -146,6 +146,35 @@ func TestSubcommandsCompleteArgs(t *testing.T) { } } +// TestSubcommandsDocumentThemselves is what makes `--help` usable as the +// reference. The README used to carry a per-command section explaining each +// command's reasoning and showing worked invocations; that moved into the +// commands themselves (#102), so the README could stop being a second copy +// that drifts. +// +// The cost of that move is a new way to regress: a command added with a +// one-line Long and no Example leaves its behaviour documented nowhere at all, +// and nothing else would notice. A length floor rather than a non-empty check, +// because "Create pages." satisfies non-empty and explains nothing. +func TestSubcommandsDocumentThemselves(t *testing.T) { + rootCmd.InitDefaultCompletionCmd() + for _, c := range rootCmd.Commands() { + // Cobra's own commands document themselves. + if c.Name() == "help" || c.Name() == "completion" { + continue + } + if len(c.Long) < 120 { + t.Errorf("subcommand %q has a Long of %d chars; it is the reference for "+ + "what the command does and why, so it needs more than a restated Short", + c.Name(), len(c.Long)) + } + if c.Example == "" { + t.Errorf("subcommand %q has no Example; a worked invocation is what a "+ + "reader wants first, and the README no longer carries one", c.Name()) + } + } +} + // TestSecurityWarnerIsWired pins the one line that makes the .env permission // warning exist at runtime. Everything else about it is tested in // internal/client (the predicate) and internal/ui (the output), each against diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 54ae433..898654c 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -22,6 +22,10 @@ var Cmd = &cobra.Command{ "validate real --json output read that same embedded copy.\n\n"+ "The output is the schema document itself, so --json changes nothing here.", jsonout.SchemaVersion), + Example: " # Save the schema\n" + + " markfluence schema > schema.json\n\n" + + " # Which commands emit a --json envelope\n" + + " markfluence schema | jq -r '.properties.command.enum | join(\" \")'\n", Args: cobra.NoArgs, // The command takes no arguments; without this, completion would offer every // file in the directory. diff --git a/cmd/search/search.go b/cmd/search/search.go index 1a1df52..c0e66ca 100644 --- a/cmd/search/search.go +++ b/cmd/search/search.go @@ -62,6 +62,14 @@ var Cmd = &cobra.Command{ "Neither are folders, which have no text to match -- use `find` for\n" + "both of those.\n\n" + "Finding nothing is a success: the command says so and exits 0.", + Example: " # Full-text search; every word must appear somewhere\n" + + " markfluence search \"deploy runbook\"\n\n" + + " # Scoped, with a bigger page of results\n" + + " markfluence search \"deploy runbook\" --space ENG --limit 25\n\n" + + " # Every match, ids only\n" + + " markfluence search deploy --limit all --json | jq -r '.results[].id'\n\n" + + " # Raw CQL, passed through untouched\n" + + " markfluence search 'type = page and label = \"runbook\"' --cql\n", Args: cobra.ExactArgs(1), // A query is free text and a space key lives on the server, which completion // may not go ask for. diff --git a/cmd/update/update.go b/cmd/update/update.go index 9dacac7..5ccd893 100644 --- a/cmd/update/update.go +++ b/cmd/update/update.go @@ -42,9 +42,35 @@ var Cmd = &cobra.Command{ Long: "Publish one or more markdown FILEs to Confluence pages.\n\n" + "Title and page id are read from each file's YAML frontmatter; --title and\n" + "--page-id override the frontmatter (and require a single FILE). A page id is\n" + - "required (from --page-id or frontmatter). Page width is asserted only when\n" + - "set via --page-width or a page_width frontmatter line. Each file is processed\n" + - "independently; the command exits non-zero if any file failed.", + "required (from --page-id or frontmatter); update errors if none is set.\n\n" + + "Page width is asserted only when set via --page-width or a page_width\n" + + "frontmatter line -- otherwise the live page's width is left untouched.\n" + + "Labels work the same way: a labels: line is asserted exactly (anything on\n" + + "the page the file does not list is removed), and no labels: line means the\n" + + "page's labels are left alone, not even read.\n\n" + + "update never writes back to the file, so fixing a wrong page_id is always\n" + + "safe: the file is exactly as you left it. A page_id that no longer resolves\n" + + "fails that file and says what to do about it; one that is not a numeric id\n" + + "at all is reported without asking Confluence.\n\n" + + "A file that has not changed since the page's last version is skipped,\n" + + "compared by mtime, unless --force is given. Each file is processed\n" + + "independently; the command exits non-zero if any file failed.\n\n" + + "--dry-run previews the version bump, attachment uploads and any width or\n" + + "label change without writing to Confluence. It honours the mtime skip and\n" + + "--force exactly as a real run does, so its forecast matches.", + Example: " # Publish a file, taking the page id from its frontmatter\n" + + " markfluence update docs/managing_an_incident.md\n\n" + + " # Publish a batch with a version message\n" + + " markfluence update docs/*.md --message \"Bulk update\"\n\n" + + " # Republish even though the file has not changed\n" + + " markfluence update docs/foo.md --force\n\n" + + " # Override the target page, or rename it\n" + + " markfluence update page.md --page-id 123456\n" + + " markfluence update page.md --title \"New Title\"\n\n" + + " # Set the width across a batch\n" + + " markfluence update docs/*.md --page-width wide\n\n" + + " # Preview, write nothing\n" + + " markfluence update docs/*.md --dry-run", Args: cobra.MinimumNArgs(1), ValidArgsFunction: completion.MarkdownFiles, RunE: run, diff --git a/docs/commands/markfluence.md b/docs/commands/markfluence.md new file mode 100644 index 0000000..465bd35 --- /dev/null +++ b/docs/commands/markfluence.md @@ -0,0 +1,55 @@ +## markfluence + +Publish markdown to Confluence + +### Synopsis + +markfluence publishes and manipulates Confluence pages from markdown files. + +Configuration resolves with the precedence flag > environment variable > +.env file. The site URL (--url / CONFLUENCE_URL), username (--username / +CONFLUENCE_USERNAME), and cloud ID (--cloud-id / CONFLUENCE_CLOUD_ID) may be +set any of those ways; the API token (CONFLUENCE_TOKEN) comes only from the +environment or .env, never a flag. + +Set the cloud ID to authenticate with a scoped API token, such as one issued +to a service account: those tokens are rejected against the site domain and +must go through Atlassian's api.atlassian.com gateway. Leave it unset for an +unscoped personal token. Find yours at +https://YOUR-SITE.atlassian.net/_edge/tenant_info -- it isn't a secret. + +``` +markfluence [flags] +``` + +### Options + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + -h, --help help for markfluence + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence attachment-download](markfluence_attachment-download.md) - Download a Confluence page's attachments +* [markfluence attachment-list](markfluence_attachment-list.md) - List a Confluence page's attachments +* [markfluence attachment-upload](markfluence_attachment-upload.md) - Upload or replace attachments on a Confluence page +* [markfluence check](markfluence_check.md) - Validate markdown files against the converter and frontmatter rules, offline +* [markfluence children](markfluence_children.md) - List the pages and folders under a Confluence page, folder, or space +* [markfluence create](markfluence_create.md) - Create new Confluence pages from markdown files +* [markfluence export](markfluence_export.md) - Write a Confluence page and its attachments to a directory +* [markfluence find](markfluence_find.md) - Find Confluence pages and folders by exact title +* [markfluence fix](markfluence_fix.md) - Reconcile each markdown file's frontmatter to its live Confluence page +* [markfluence info](markfluence_info.md) - Print metadata about a Confluence page +* [markfluence read](markfluence_read.md) - Fetch a Confluence page and print its body +* [markfluence schema](markfluence_schema.md) - Print the JSON Schema for --json output +* [markfluence search](markfluence_search.md) - Find Confluence pages by full-text search +* [markfluence update](markfluence_update.md) - Publish one or more markdown files to Confluence pages + diff --git a/docs/commands/markfluence_attachment-download.md b/docs/commands/markfluence_attachment-download.md new file mode 100644 index 0000000..69cab06 --- /dev/null +++ b/docs/commands/markfluence_attachment-download.md @@ -0,0 +1,77 @@ +## markfluence attachment-download + +Download a Confluence page's attachments + +### Synopsis + +Download a Confluence page's attachments. + +PAGE is a numeric page id, a Confluence page URL, or a markdown file +whose frontmatter has a page_id. Each NAME is an attachment name as +attachment-list reports it; with no NAME, every attachment is +downloaded. + +An attachment markfluence published records the markdown image path it +came from, and is written back to that path under --dest, so the +downloaded tree matches what the page's markdown references and +previews locally. + +An attachment without a recorded path -- one that originated in +Confluence -- is written under a directory named after the page, since +an attachment name is unique per page and not per space: two pages' +diagram.png would otherwise be one file. That is where `read` and +`export` point at it too. + +--flat writes everything directly under --dest, under stored names. + +A recorded path that would resolve outside --dest is refused for that +attachment, since the path comes from an attachment comment anyone who +can edit the page controls. + +A file that already exists is skipped unless --force. + +``` +markfluence attachment-download PAGE [NAME...] [flags] +``` + +### Examples + +``` + # Every attachment, to the paths they were published from + markfluence attachment-download 1234567890 --dest ./out + + # Just one, by its stored name + markfluence attachment-download 1234567890 diagram.png --dest ./out + + # Ignore recorded paths and write everything flat + markfluence attachment-download 1234567890 --dest ./out --flat + +``` + +### Options + +``` + --dest string Directory to write attachments into. (default ".") + --dry-run Preview what would be written without creating any files. + --flat Write every attachment under its stored name, ignoring recorded paths. + --force Overwrite files that already exist. + -h, --help help for attachment-download +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_attachment-list.md b/docs/commands/markfluence_attachment-list.md new file mode 100644 index 0000000..f2fcd77 --- /dev/null +++ b/docs/commands/markfluence_attachment-list.md @@ -0,0 +1,58 @@ +## markfluence attachment-list + +List a Confluence page's attachments + +### Synopsis + +List a Confluence page's attachments. + +PAGE is a numeric page id, a Confluence page URL, or a markdown file +whose frontmatter has a page_id. + +The NAME column is the name Confluence stores, which is what +attachment-download takes. For an image markfluence published that is +the encoded source path, and the SOURCE column shows the markdown +image path it came from. + +SOURCE is a dash when no source path is recorded: the attachment was +uploaded by hand, or it was published before markfluence recorded one. +Use --json, whose managed field tells those two apart. + +``` +markfluence attachment-list PAGE [flags] +``` + +### Examples + +``` + # Every attachment on a page + markfluence attachment-list 1234567890 + + # By the file that publishes to it + markfluence attachment-list docs/foo.md + +``` + +### Options + +``` + -h, --help help for attachment-list +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_attachment-upload.md b/docs/commands/markfluence_attachment-upload.md new file mode 100644 index 0000000..8b1c6d4 --- /dev/null +++ b/docs/commands/markfluence_attachment-upload.md @@ -0,0 +1,71 @@ +## markfluence attachment-upload + +Upload or replace attachments on a Confluence page + +### Synopsis + +Upload or replace attachments on a Confluence page. + +PAGE is a numeric page id, a Confluence page URL, or a markdown file +whose frontmatter has a page_id. + +Each file is attached under its base name, with its path relative to +the documentation root recorded in the attachment's comment. A file +whose contents already match the attachment on the page is skipped, +using the same checksum bookkeeping create/update use, so uploading by +hand and publishing agree on what is current; --force uploads anyway. + +--name takes a path, not a name, for a single file: `--name +assets/x.png` produces the attachment an image written as +![](assets/x.png) resolves to -- stored as x.png, recorded as +assets/x.png. + +Two files whose base names agree cannot both be uploaded to one page, +since an attachment name is unique per page; that is refused rather +than silently overwriting. + +``` +markfluence attachment-upload PAGE FILE... [flags] +``` + +### Examples + +``` + # Upload one file, or several + markfluence attachment-upload 1234567890 diagram.png + markfluence attachment-upload 1234567890 report.pdf notes.txt + + # Store it under the path a markdown image would reference + markfluence attachment-upload 1234567890 img.png --name assets/diagram.png + + # Re-upload even though the checksum matches + markfluence attachment-upload 1234567890 diagram.png --force + +``` + +### Options + +``` + --dry-run Preview what would be uploaded without writing to Confluence. + --force Upload even when the checksum shows the attachment is unchanged. + -h, --help help for attachment-upload + --name string Attachment name, given as a path (requires a single FILE). +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_check.md b/docs/commands/markfluence_check.md new file mode 100644 index 0000000..85b711c --- /dev/null +++ b/docs/commands/markfluence_check.md @@ -0,0 +1,61 @@ +## markfluence check + +Validate markdown files against the converter and frontmatter rules, offline + +### Synopsis + +Validate one or more markdown FILEs against the converter and frontmatter +rules, with no network access and no credentials -- fast, safe, and +CI/agent-friendly. Reports conversion warnings and broken image/link +references, and frontmatter sanity (parseable, page_width valid, page_id +numeric when present). Each file is processed independently; the command +exits non-zero if any file is broken or failed outright. Warnings alone do +not fail. + +"link not resolved: TARGET" means TARGET is a sibling .md file that exists +under the documentation root but has no page_id yet -- the normal state of +a tree that hasn't been published, not a defect. "same-page anchor not +resolved: #heading" is the same situation for a same-page anchor: it +resolves to a real heading in the current file, but can't be turned into +an absolute URL until this file itself has a page_id -- resolved by this +file's own first publish, nothing to fix. + +``` +markfluence check FILE... [flags] +``` + +### Examples + +``` + # Validate a batch of files + markfluence check docs/*.md + + # Show the storage HTML a publish would send + markfluence check --show-html docs/one-page.md + +``` + +### Options + +``` + -h, --help help for check + --show-html Also print the converted storage HTML and attachment list, for debugging. +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_children.md b/docs/commands/markfluence_children.md new file mode 100644 index 0000000..951636f --- /dev/null +++ b/docs/commands/markfluence_children.md @@ -0,0 +1,75 @@ +## markfluence children + +List the pages and folders under a Confluence page, folder, or space + +### Synopsis + +List the pages and folders under a Confluence page or folder. + +PAGE is a numeric id, a Confluence page or folder URL, or a markdown +file whose frontmatter has a page_id. + +Pass --space KEY instead of a PAGE to list a whole space. Depth 1 is +then the space's top level, which is usually just its homepage, so +--depth 2 or --depth all is what shows the tree. Walking a space costs +one pair of requests per page and folder in it. + +Folders are listed alongside pages, with a TYPE column, because a +folder can hold the only pages in a subtree -- listing pages alone +would show nothing for a folder that contains folders. + +A folder counts as a level: at the default --depth 1 a child folder +appears as a row, and --depth 2 shows what is inside it. + +``` +markfluence children [PAGE] [flags] +``` + +### Examples + +``` + # Direct children of a page + markfluence children 1234567890 + + # Deeper, or the whole subtree + markfluence children 1234567890 --depth 3 + markfluence children 1234567890 --depth all + + # By folder URL, or by the file that publishes to a page + markfluence children "https://org.atlassian.net/wiki/spaces/ENG/folder/1234567890" + markfluence children docs/index.md + + # A whole space, and every page and folder in it + markfluence children --space ENG + markfluence children --space ENG --depth all + + # Just the page ids + markfluence children 1234567890 --json | jq -r '.results[] | select(.type=="page") | .id' + +``` + +### Options + +``` + --depth string How deep to recurse: a positive number, or "all". (default "1") + -h, --help help for children + --space string List a whole space, by key, instead of a PAGE. +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_create.md b/docs/commands/markfluence_create.md new file mode 100644 index 0000000..63db7be --- /dev/null +++ b/docs/commands/markfluence_create.md @@ -0,0 +1,95 @@ +## markfluence create + +Create new Confluence pages from markdown files + +### Synopsis + +Create new Confluence pages from markdown FILEs. + +The title comes from frontmatter, or from --title, which overrides it and +requires a single FILE. The space comes from --space or frontmatter. The +parent comes from --parent or frontmatter and may be a page or a Cloud +folder -- give a folder's id the same way you would a page's. Page width +defaults to max. + +Every file is checked first -- including converting it -- and if any would +fail, nothing is created. A page_id that resolves to nothing is a failure +too, not a fresh page: create will not publish a second copy and overwrite +an id it cannot explain. Remove the page_id to create a new page, or +correct it. + +Once every file passes, a content-less stub is reserved for each, +parents-first, before any of them is converted -- so a link between two +files in the same batch resolves regardless of which direction it points, +or whether the two link to each other. A parent cycle among the given +files is rejected instead. A run interrupted after the reserve phase +leaves an empty page version behind rather than no page; every id is +already written back, so a plain update finishes the job. + +A whole tree can be created in one pass: give each child a parent: that +points at its parent's .md file, and creation is ordered parents-first +with the real ids filled in. + +Unless --no-persist is given, each created page's +title/space/parent/page_id/page_width/labels are written back into the +frontmatter. + +--dry-run makes the same checks as a real run, so it exits non-zero on the +same failures and one unpublishable file aborts the preview for the whole +batch. To lint several files independently, use check instead. + +``` +markfluence create FILE... [flags] +``` + +### Examples + +``` + # Create one page in a space + markfluence create docs/new_page.md --space ENG + + # Create it under an existing parent page or folder + markfluence create docs/child.md --space ENG --parent 123456 + + # Create a whole tree, hierarchy taken from each file's parent: path + markfluence create docs/*.md --space ENG + + # Override the title and width for a single file + markfluence create note.md --space ENG --title "Ad-hoc note" --page-width wide + + # Create without writing page_id back into the file + markfluence create note.md --space ENG --no-persist + + # Preview everything, write nothing + markfluence create docs/*.md --space ENG --dry-run +``` + +### Options + +``` + --dry-run Preview what would be created without writing to Confluence or files. + -h, --help help for create + --no-persist Do not write anything back into the frontmatter. + --page-width string Override the page width: narrow, wide, or max. + --parent string Parent page or folder id for the new page(s). + --space string Target space key. + --title string Override the page title (requires a single FILE). +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_export.md b/docs/commands/markfluence_export.md new file mode 100644 index 0000000..fa258bf --- /dev/null +++ b/docs/commands/markfluence_export.md @@ -0,0 +1,91 @@ +## markfluence export + +Write a Confluence page and its attachments to a directory + +### Synopsis + +Write a Confluence page and the attachments it uses to a directory. + +PAGE is a numeric page id, a Confluence page or folder URL, or a +markdown file whose frontmatter has a page_id. A folder has no content +of its own, so it is a target only with --depth: what is inside it +becomes the top level of the export. + +Pass --space KEY instead of a PAGE to export a whole space, whose root +pages become the top level. It needs an explicit --depth, since a space +walk is one pair of requests per page and folder in it and should be +asked for rather than typed by accident. + +The page is written as markdown with title/space/parent/page_id/ +page_width frontmatter, so an exported file can be edited and +published back with update. For a page at the top of the export this +is exactly what `read` prints; deeper in a tree the paths in it are +relative to where the file sits, which `read` cannot know. + +--depth exports the page's descendants too, mirroring the Confluence +hierarchy: a page becomes .md with a / beside it for its +children, a folder becomes a directory, and each child's parent: +points at its parent's file so the tree can be published into fresh +pages. It costs a pair of requests per page and folder walked, plus +the page's own. + +Attachments markfluence published are written to the paths their +images came from; one that originated in Confluence is written under +the page's own directory, since attachment names are unique per page +and not per space. Only attachments the page references are exported; +--all-attachments takes everything on the page. + +This is the one-command form of `read` plus `attachment-download`. + +``` +markfluence export [PAGE] [flags] +``` + +### Examples + +``` + # One page and the attachments it uses + markfluence export 1234567890 --dest ./out + + # The page and its whole subtree, hierarchy mirrored on disk + markfluence export 1234567890 --depth all --dest out + + # A whole space; --depth is required for a space walk + markfluence export --space ENG --depth all --dest out + + # Re-export a tree whose pages changed upstream + markfluence export 1234567890 --depth all --dest out --force + +``` + +### Options + +``` + --all-attachments Export every attachment on the page, not just the referenced ones. + --depth string How deep to export: 0 for the page alone, a positive number, or "all". (default "0") + --dest string Directory to write the export into. (default ".") + --dry-run Preview what would be written without creating any files. + --file string Name for the page file (default: a slug of the title, or the page id if that slugs to nothing). + --force Overwrite files that already exist. + -h, --help help for export + --skip-attachments Write the page file only. + --space string Export a whole space, by key, instead of a PAGE. +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_find.md b/docs/commands/markfluence_find.md new file mode 100644 index 0000000..8defc9b --- /dev/null +++ b/docs/commands/markfluence_find.md @@ -0,0 +1,62 @@ +## markfluence find + +Find Confluence pages and folders by exact title + +### Synopsis + +Find Confluence pages and folders whose title matches TITLE. + +The match is exact and case-insensitive -- not a substring search. + +Both current and archived pages are reported. An archived page is +invisible in the page tree but still reserves its title, so it will +block creating a page with that title in the same space. + +Folders are reported too, since a folder id is a legitimate parent. +A folder does not reserve a title, so a folder hit is never a reason +a page cannot be created -- it is there to be found, not to warn. + +Finding nothing is a success: the command says so and exits 0. + +``` +markfluence find TITLE [flags] +``` + +### Examples + +``` + # Every page, archived page and folder with this exact title + markfluence find "Deploy runbook" + + # Scoped to one space + markfluence find "Deploy runbook" --space ENG + + # Just the current page ids + markfluence find "Deploy runbook" --json | jq -r '.results[] | select(.type=="page") | .id' + +``` + +### Options + +``` + -h, --help help for find + --space string Restrict the search to a space, by key (an unknown key is an error, not an empty result). +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_fix.md b/docs/commands/markfluence_fix.md new file mode 100644 index 0000000..944bb82 --- /dev/null +++ b/docs/commands/markfluence_fix.md @@ -0,0 +1,68 @@ +## markfluence fix + +Reconcile each markdown file's frontmatter to its live Confluence page + +### Synopsis + +Reconcile each markdown file's frontmatter to its live Confluence page. + +Populates/refreshes page_id, space, parent, page_width and labels (and +fills a missing title) from the live page. The page is located by page_id, +or by searching for the title when page_id is absent. fix never creates, +updates or moves pages -- it is read-only on the server. Each file is +processed independently; the command exits non-zero if any file failed. + +It writes a file when a field changed, and also when the frontmatter keys +are out of canonical order (title, space, parent, page_id, then the rest +alphabetically), which is reported separately as reordered. --dry-run +reports both without writing. + +Labels are reconciled even for a file with no labels: line, which is how +you adopt a page somebody labeled in the UI. That is the one place fix +fills in a field update would have left alone, because fix reconciles the +file to the page rather than the page to the file. + +parent is written as the live page's parent id. In a tree written by +`export --depth`, where parent points at the parent's own .md file, +fix therefore replaces that path with an id -- consistent with +reconciling to the live page, and worth knowing before running it over +an exported tree. + +``` +markfluence fix FILE... [flags] +``` + +### Examples + +``` + # Reconcile a batch of files to their live pages + markfluence fix docs/*.md + + # Report what would change, write nothing + markfluence fix docs/foo.md --dry-run +``` + +### Options + +``` + --dry-run Report the changes fix would make without writing any files. + -h, --help help for fix +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_info.md b/docs/commands/markfluence_info.md new file mode 100644 index 0000000..7f109e9 --- /dev/null +++ b/docs/commands/markfluence_info.md @@ -0,0 +1,56 @@ +## markfluence info + +Print metadata about a Confluence page + +### Synopsis + +Print metadata about a Confluence page. + +Id, title, status, space, parent, version, page width, labels, the +created/updated author stamps, and the page URL. An empty field is +omitted rather than printed blank. + +PAGE is a numeric page id, a Confluence page URL, or a markdown file +whose frontmatter has a page_id. + +--properties also lists every one of the page's content properties, which +is where Confluence keeps things like the page width. + +``` +markfluence info PAGE [flags] +``` + +### Examples + +``` + # By page id + markfluence info 1234567890 + + # By the file that publishes to it, with content properties + markfluence info docs/foo.md --properties +``` + +### Options + +``` + -h, --help help for info + --properties Also list all of the page's content properties. +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_read.md b/docs/commands/markfluence_read.md new file mode 100644 index 0000000..93fd33f --- /dev/null +++ b/docs/commands/markfluence_read.md @@ -0,0 +1,72 @@ +## markfluence read + +Fetch a Confluence page and print its body + +### Synopsis + +Fetch a Confluence page and print its body to stdout. + +PAGE is a numeric page id, a Confluence page URL (the modern +/wiki/.../pages//... form or a legacy ?pageId= URL), or a +markdown file whose frontmatter has a page_id. + +It composes with shell redirection. + +--format markdown (the default) carries +title/space/parent/page_id/labels/page_width frontmatter and is a +best-effort inverse of what create/update publish. The Confluence API has +no markdown representation, so the storage body is converted here: +constructs markfluence emits round-trip faithfully, while editor-authored +content degrades gracefully -- a macro markfluence does not map, and a +column layout, pass through as raw storage tags with their bodies kept as +readable markdown, so they publish back unchanged. Some transforms are +lossy (a table cell colour outside the named swatches comes back as a +literal hex), so this is a reading aid rather than a guaranteed source +round-trip. + +--format storage prints the raw storage-format XHTML exactly as stored. + +``` +markfluence read PAGE [flags] +``` + +### Examples + +``` + # Markdown, with frontmatter, to stdout + markfluence read 1234567890 + + # Save it as a file you can edit and publish back + markfluence read 1234567890 > page.md + + # The raw storage Confluence holds + markfluence read 1234567890 --format storage > page.storage.xml + + # By URL + markfluence read "https://org.atlassian.net/wiki/spaces/ENG/pages/1234567890/Title" +``` + +### Options + +``` + --format string Output format: markdown (default) or storage (default "markdown") + -h, --help help for read +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_schema.md b/docs/commands/markfluence_schema.md new file mode 100644 index 0000000..811d33c --- /dev/null +++ b/docs/commands/markfluence_schema.md @@ -0,0 +1,54 @@ +## markfluence schema + +Print the JSON Schema for --json output + +### Synopsis + +Print the JSON Schema (draft 2020-12) that markfluence's --json output +conforms to, so a script, a CI job, or an agent can fetch the contract from +the binary instead of the repository. + +The schema is embedded at build time and describes schema_version 1, the +version this binary emits. Both the schema command and the tests that +validate real --json output read that same embedded copy. + +The output is the schema document itself, so --json changes nothing here. + +``` +markfluence schema [flags] +``` + +### Examples + +``` + # Save the schema + markfluence schema > schema.json + + # Which commands emit a --json envelope + markfluence schema | jq -r '.properties.command.enum | join(" ")' + +``` + +### Options + +``` + -h, --help help for schema +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_search.md b/docs/commands/markfluence_search.md new file mode 100644 index 0000000..0c8aeb4 --- /dev/null +++ b/docs/commands/markfluence_search.md @@ -0,0 +1,71 @@ +## markfluence search + +Find Confluence pages by full-text search + +### Synopsis + +Find Confluence pages whose text matches QUERY. + +QUERY is matched against the page's full text, not just its title. +Multiple words are ANDed: every word must appear somewhere in the +page, in any order. It is not a phrase search, so quoting a phrase +does not require the words to be adjacent. + +Results come back in Confluence's own relevance order, best first, +and are capped at --limit. When more matches exist than were shown, +the command says so rather than truncating silently. + +Archived pages are never returned: the search index cannot see them. +Neither are folders, which have no text to match -- use `find` for +both of those. + +Finding nothing is a success: the command says so and exits 0. + +``` +markfluence search QUERY [flags] +``` + +### Examples + +``` + # Full-text search; every word must appear somewhere + markfluence search "deploy runbook" + + # Scoped, with a bigger page of results + markfluence search "deploy runbook" --space ENG --limit 25 + + # Every match, ids only + markfluence search deploy --limit all --json | jq -r '.results[].id' + + # Raw CQL, passed through untouched + markfluence search 'type = page and label = "runbook"' --cql + +``` + +### Options + +``` + --cql Treat QUERY as a raw CQL query instead of text to search for; cannot be combined with --space or an explicit --type (put those clauses in the query). + -h, --help help for search + --limit string How many matches to show: a positive number, or "all". (default "10") + --space string Restrict the search to a space, by key. + --type string Content type to search: "page", "blogpost", or "all". (default "page") +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/commands/markfluence_update.md b/docs/commands/markfluence_update.md new file mode 100644 index 0000000..038a5a1 --- /dev/null +++ b/docs/commands/markfluence_update.md @@ -0,0 +1,87 @@ +## markfluence update + +Publish one or more markdown files to Confluence pages + +### Synopsis + +Publish one or more markdown FILEs to Confluence pages. + +Title and page id are read from each file's YAML frontmatter; --title and +--page-id override the frontmatter (and require a single FILE). A page id is +required (from --page-id or frontmatter); update errors if none is set. + +Page width is asserted only when set via --page-width or a page_width +frontmatter line -- otherwise the live page's width is left untouched. +Labels work the same way: a labels: line is asserted exactly (anything on +the page the file does not list is removed), and no labels: line means the +page's labels are left alone, not even read. + +update never writes back to the file, so fixing a wrong page_id is always +safe: the file is exactly as you left it. A page_id that no longer resolves +fails that file and says what to do about it; one that is not a numeric id +at all is reported without asking Confluence. + +A file that has not changed since the page's last version is skipped, +compared by mtime, unless --force is given. Each file is processed +independently; the command exits non-zero if any file failed. + +--dry-run previews the version bump, attachment uploads and any width or +label change without writing to Confluence. It honours the mtime skip and +--force exactly as a real run does, so its forecast matches. + +``` +markfluence update FILE... [flags] +``` + +### Examples + +``` + # Publish a file, taking the page id from its frontmatter + markfluence update docs/managing_an_incident.md + + # Publish a batch with a version message + markfluence update docs/*.md --message "Bulk update" + + # Republish even though the file has not changed + markfluence update docs/foo.md --force + + # Override the target page, or rename it + markfluence update page.md --page-id 123456 + markfluence update page.md --title "New Title" + + # Set the width across a batch + markfluence update docs/*.md --page-width wide + + # Preview, write nothing + markfluence update docs/*.md --dry-run +``` + +### Options + +``` + --dry-run Preview what would be published without writing to Confluence. + --force Skip the file-mtime check and always update the page. + -h, --help help for update + --message string Version message. (default "Updated via markfluence") + --page-id string Override the target page id (requires a single FILE). + --page-width string Override the page width: narrow, wide, or max. + --title string Override the page title (requires a single FILE). +``` + +### Options inherited from parent commands + +``` + --cloud-id string Atlassian cloud ID; set to use a scoped API token via the api.atlassian.com gateway (falls back to $CONFLUENCE_CLOUD_ID, then .env) + -d, --debug Enable verbose debug output + --env-file string Path to an env file to read (default: .env at the discovered project root, or the working directory if none) + --json Emit machine-readable JSON to stdout instead of human output + --no-color Disable colored output + --root string Documentation root, overriding discovery (default: the directory holding markfluence.yaml, found by walking up from each file, or the file's own directory if none) + --url string Confluence base URL (falls back to $CONFLUENCE_URL, then .env) + --username string Confluence username/email (falls back to $CONFLUENCE_USERNAME, then .env) +``` + +### SEE ALSO + +* [markfluence](markfluence.md) - Publish markdown to Confluence + diff --git a/docs/github-actions.md b/docs/github-actions.md new file mode 100644 index 0000000..5f4e1eb --- /dev/null +++ b/docs/github-actions.md @@ -0,0 +1,79 @@ +# Using with GitHub Actions + +Run markfluence in CI to keep Confluence pages in sync with the markdown in +your repo: on a push to your default branch, publish the docs that changed. + +Configuration in general — including how credentials resolve and what a scoped +token needs — is in the [README](../README.md#configure). + +You will need to know the Confluence `page_id` for each page you want to +update. + +## Credentials + +Store environment variables as [encrypted secret][secrets] (never commit them). +markfluence reads them straight from the environment — no `.env` in CI. + +- `CONFLUENCE_TOKEN` +- `CONFLUENCE_URL` +- `CONFLUENCE_USERNAME` + +Prefer a [service account][svcacct] over a personal token here, so published pages +aren't authored by an individual and publishing doesn't break when that person +rotates their token or moves on. That means a **scoped** token, which also needs +`CONFLUENCE_CLOUD_ID` (see [Scoped tokens and service +accounts](../README.md#scoped-tokens-and-service-accounts)). The cloud ID is not sensitive, so +make it a repository **variable** rather than a secret. + +[secrets]: https://docs.github.com/en/actions/security-guides/using-secrets-in-github-actions + +## Workflow + +```yaml +name: Publish docs to Confluence + +on: + push: + branches: [main] + paths: ['docs/**.md'] # only when docs change + +# Avoid overlapping publishes racing on the same pages. +concurrency: + group: confluence-publish + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + # No release binaries are published yet, so install from source. Pin a tag + # (…@v1.2.3) once releases exist, rather than @latest, for reproducibility. + - name: Install markfluence + run: go install github.com/mozilla/markfluence@latest + + - name: Publish + env: + CONFLUENCE_URL: ${{ secrets.CONFLUENCE_URL }} + CONFLUENCE_USERNAME: ${{ secrets.CONFLUENCE_USERNAME }} + CONFLUENCE_TOKEN: ${{ secrets.CONFLUENCE_TOKEN }} + # A variable, not a secret: the cloud ID is public. Omit it if you're + # using an unscoped personal token. + CONFLUENCE_CLOUD_ID: ${{ vars.CONFLUENCE_CLOUD_ID }} + run: + markfluence update --page-id=12345 --force docs/some_doc.md +``` + +Notes: + +- **Exit codes.** `update` exits non-zero if any file fails, so the job fails + loudly. Add `--json` to get machine-readable per-file results on stdout (see + [`--json` output](../README.md#--json-output)) if a later step needs to parse them. + +A reusable composite/Docker action wrapping this is tracked in +[#29](https://github.com/mozilla/markfluence/issues/29). diff --git a/docs/json-output.md b/docs/json-output.md new file mode 100644 index 0000000..d359907 --- /dev/null +++ b/docs/json-output.md @@ -0,0 +1,82 @@ +# `--json` output, in detail + +The envelope, an example, and the exit codes are in the +[README](../README.md#--json-output); the authoritative field-by-field contract +is the JSON Schema at +[`schema/json-output/v1.json`](../schema/json-output/v1.json), which +`markfluence schema` also prints. + +This file is the part that is neither: why the shapes are what they are, and the +per-command details a script author hits once and then needs to look up. + + +## Notes on the schema + +- **Per-command stable.** Each command always emits the same keys in the same + shapes (empty values are `null` or `[]`); the key *set* differs per command. + `schema_version` is bumped on any breaking change. +- **`roots`** lists every distinct [documentation root](../README.md#the-documentation-root) + the command resolved, sorted — `[]` for a command with no per-file root + concept (`find`, `search`, ...) or a pre-flight failure that never reached + root resolution. `schema` emits no envelope at all, so it has no `roots` key + to speak of. +- **`warnings`** carries warnings about the *invocation* rather than about any + page or file — currently only the `.env` permission warning below. A result's + own warnings live on the result; this is for something that belongs to no + result. `[]` when there is nothing to report. It appears on the stderr error + object too, since a fatal failure emits no envelope and a credential failure + is exactly the run where a warning about your `.env` matters. +- **Status verbs** are per-command: `published`/`skipped` (`update`), + `created`/`not_created` (`create`), `changed`/`consistent` (`fix`), + `clean`/`warnings`/`broken` (`check`), + `created`/`updated`/`skipped` (`attachment-upload`), + `downloaded`/`skipped` (`attachment-download`), plus `failed`. `info`, `read`, + and `attachment-list` results carry data only (no status verb). +- **One result per target**, and the target is per-command: the page for + `info`/`read`/`export` (always one), the file for `update`/`create`/`fix`/`check`, + and the attachment for the three `attachment-*` commands — so + `.results[] | .filename` works and `summary.total` is the attachment count. + `export` nests the files it wrote in an `attachments` array on its page + result, the way `update`/`create` do. +- **`check`'s `broken` status is `ok: false` with no `error`/`code`** — unlike + every other failure, its `broken`/`warnings` arrays already say everything + there is to say, so there's no separate operational error to attach. Only + its `failed` status (a file that never reached the converter at all) sets + them, the same as every other command's failure. `check --show-html` adds a + `debug: { html, attachments } | null` field, populated only for a file that + reached the converter; `html` stays exactly what the converter produced + (unindented), since it's meant to match what `update`/`create` would + literally publish. +- **Compound values are objects**, never display strings — `version`, + `page_width`, and the `created`/`updated` author stamps on `info`. +- **`create`'s preflight abort** (any file failing means nothing is created) + lists every input file — failed ones with an `error`, the rest as + `not_created` — and sets `summary.aborted: true`. +- **Warnings and broken image/link notices** are data (`warnings`/`broken` + arrays on each result), not stderr log lines. +- **The discovery commands list what they found**, so `results` is one object per + match (`find`, `search`) or per node (`children`), and `summary.total` is that + count. `search`'s summary carries two extra fields: `truncated`, meaning + `--limit` was reached with matches left over, and `skipped`, counting index rows + that had no page id to report (reachable only via `--cql` or `--type all`). + Neither is a count of matches you could get by asking again for more. + +Errors and exit codes: + +- **Per-file operational failures** appear in `results` as + `{ "ok": false, "error": "…", "code": "…" }`; the command exits `1` if any + file failed. +- **`find` and `search` have no failed-result variant.** They name no page, so + there is no id to attach a failure to: an operational failure prints the same + typed error object to **stderr** and exits `1`, with no envelope on stdout. + Emitting an empty `results` array would be worse than emitting nothing, since + "no matches" is a meaningful answer that a caller acts on. +- **Fatal/pre-flight failures** (bad flags, credential resolution) print a typed + error object to **stderr** and exit `2`: + + ```json + { "schema_version": 1, "command": "update", "error": "…", "code": "CONFIG", "warnings": [] } + ``` + +- Error `code` values: `CONFIG`, `AUTH`, `NOT_FOUND`, `VALIDATION`, `CONVERT`, + `IO`, `NETWORK`, `API`. diff --git a/docs/markdown_file.md b/docs/markdown_file.md new file mode 100644 index 0000000..ec79409 --- /dev/null +++ b/docs/markdown_file.md @@ -0,0 +1,356 @@ +# A markfluence markdown file + +Each Markdown file is one Confluence page: an optional YAML **frontmatter** block +followed by the Markdown **body**. + +``` +--- +title: My Page Title +space: ENG +parent: null +page_id: 1234567890 +page_width: max +--- + +# Body starts here +... +``` + +## Frontmatter + +Frontmatter is a **YAML** block delimited by `---` lines, restricted to flat +`key: value` pairs. A value is a single-line scalar, or a list of them — written +either inline (`labels: [a, b]`) or as `- ` lines. The fields markfluence reads +as single values (`title`, `space`, `parent`, `page_id`, `page_width`) are an +error when written as a list, rather than being read as unset. No nesting, and no multi-line +values. That restriction is enforced: a nested value, a `|` block, a duplicate +key, a tab indent, or a list item split over two lines is an error naming the +key, not something read as blank. Full-line `#` comments and trailing inline +` # ...` comments are preserved when markfluence rewrites a block, and a list +keeps whichever of the two spellings you wrote it in. + +Because it is real YAML, a value that YAML would read as something other than a +plain string has to be quoted — a colon-space (`title: "Deploy Runbook: Part 2"`), +a leading `#`, `[`, `{`, `@`, `*`, `&`, `%`, `!`, `|`, `>`, `-`, or `?`, leading +or trailing whitespace, and the words YAML types for you: `true`, `false`, `yes`, +`no`, `null`, `~`, and anything that looks like a number. **markfluence quotes +automatically whenever it writes a value**, so this only matters for frontmatter +you hand-write. + +`null` in any spelling (`null`, `Null`, `~`, or an empty value) means *unset*. +A page genuinely titled `null` is written `title: "null"`. + +| Field | Value domain | Notes | +| --- | --- | --- | +| `space` | a space key (e.g. `ENG`, or a personal space like `~1234abcd`) | Target space for `create` (or pass `--space`); written back by `create`. Always a key, never a numeric space id. | +| `parent` | `null`, a numeric page **or folder** id, or a relative `.md` path | `null` = top-level page; an id = an existing parent, which may be a page or a Cloud folder (the value is just an id either way — nothing records which kind it is); a `.md` path = a parent authored in the same run (`create` resolves it in dependency order, then rewrites the value to ` # `). Used by `create` (or `--parent`). | +| `page_id` | a numeric page id, or `null` | The target page. `update` looks it up by `title` and writes it back when missing; `create` writes it after creating the page. `null`/absent means "no page yet." | +| `title` | text (**required**) | The Confluence page title. | +| `labels` | a list of label names, e.g. `[ci/cd, howto]` | The page's labels. **Present means asserted exactly** — a label on the page that the file does not list is removed — and `labels: []` removes them all. **Absent means untouched**, so a page labeled by hand is safe from a run that never mentioned labels. Only `global:` labels are managed; a `my:`/`team:` label is shown by `info` and never written or removed — and if an unmanaged label shares a name with a surplus managed one, the removal is skipped with a warning, because Confluence's removal takes a name with no prefix and would delete the personal label instead. Names are lowercased (with a warning) since Confluence does that anyway; anything else invalid is an error before any write. `fix` writes back the live page's labels, which is how you adopt a page labeled in the UI. | +| `page_width` | `narrow`, `wide`, or `max` | The published page width (the UI's "Adjust width" options; `narrow`/`wide`/`max` map to the `default`/`full-width`/`max` appearance properties). Absent or blank defaults to `max`. `create`/`update` assert it on every publish (so a width set in the Confluence UI is overwritten unless the frontmatter matches); `fix` writes back the live page's width. | + +To create a page, you only need to specify the `title` in the frontmatter. + +## Body + +The rest of this file is the body reference, construct by construct: what each +markdown construct becomes in Confluence storage format. + +The design target is *semantic* equivalence to valid Confluence storage, not +byte-for-byte equality, so a construct listed here round-trips in meaning rather +than in markup. What Confluence itself does with the results — and the traps +behind several of these — is in [docs/confluence/](confluence/). + +### Fenced code blocks + +[GFM fenced code blocks](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/creating-and-highlighting-code-blocks) +are rendered as Confluence code macros, with syntax highlighting for the +languages Confluence supports. + +### Tables + +[GFM tables](https://docs.github.com/en/get-started/writing-on-github/working-with-advanced-formatting/organizing-information-with-tables) +are rendered as Confluence tables. + +#### Cell background colors + +Cell background colors can be specified using an HTML comment at the +start of the cell. They will be invisible in Markdown preview, but will have +the specified background color in Confluence. + +```markdown +| Service | Status | +| ------- | -------------------------- | +| auth | ok | +| billing | down | +``` + +The color is a swatch name from the Confluence editor's cell background palette, +or a literal `#rrggbb` hex for anything else. The 21 swatches, one row here per +column of the editor's picker: + +| Light | Medium | Bold | +| --- | --- | --- | +| `white` `#ffffff` | `light-grey` `light-gray` `#f4f5f7` | `grey` `gray` `#b3bac5` | +| `light-blue` `#deebff` | `blue` `#b3d4ff` | `bold-blue` `#4c9aff` | +| `light-teal` `#e6fcff` | `teal` `#b3f5ff` | `bold-teal` `#79e2f2` | +| `light-green` `#e3fcef` | `green` `#abf5d1` | `bold-green` `#57d9a3` | +| `light-yellow` `#fffae6` | `yellow` `#fff0b3` | `bold-yellow` `#ffc400` | +| `light-red` `#ffebe6` | `red` `#ffbdad` | `bold-red` `#ff8f73` | +| `light-purple` `#eae6ff` | `purple` `#c0b6f2` | `bold-purple` `#998dd9` | + +Details: + +- Confluence colors **cells**, not rows or columns; a colored column is + implemented with a marker per cell in the column and a colored row is + implemented with a marker per cell in the row. +- The marker works in header cells too. +- A cell holding nothing but a marker is an empty colored cell. +- The color marker has to be the first thing in the cell. Anywhere else it's + ignored with a warning, since a stray comment would otherwise do nothing + visible. +- An unknown color name is dropped with a warning and the cell publishes + uncolored. + +#### Multi-line cells + +Multi-line table cells use a literal `
      ` to break a cell onto more than +one line. A real newline can't be used instead, since a GFM table row has to +stay on one physical line. + +```markdown +| Field | Notes | +| ----- | -------------------------- | +| Key | Type: string
      JQL: "Key" | +``` + +Confluence's own editor represents a multi-line cell as separate paragraphs +rather than `
      `; `read`/`export` converts that back to the `
      ` form +shown above, which is what publishes back to the same paragraphs. + +#### Lists in cells + +Lists in table cells use HTML list tags — `
        `, `
          `, and `
        1. ` — +directly in the cell, the same way `
          ` is used for a plain line break. +Markdown's own list syntax needs each item on its own line, which a table row +can't do, so it isn't an option here. + +```markdown +| Field | Values | +| ------ | ------------------------------------- | +| Status |
          • open
          • closed
          | +``` + +`read`/`export` recovers the same tags rather than converting them to +anything else. + +### GitHub alerts + +GitHub alerts — `> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, +`[!CAUTION]` — become Confluence panels in the colour GitHub draws them in: + +| alert | colour | published as | +|---|---|---| +| `NOTE` | blue | `info` macro | +| `TIP` | green | `tip` macro | +| `IMPORTANT` | purple | ADF panel (no macro exists for purple) | +| `WARNING` | orange | `note` macro | +| `CAUTION` | red | `warning` macro | + +The mapping is one-to-one, so `read`/`export` recover the original +[GFM alert](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts). + +Example: + +```markdown +> [!NOTE] +> This is a note. +``` + +### Images + +`![alt](./path.png)` uploads a local file as an attachment (or +references a remote URL); a missing/unsupported image becomes +`line N: IMAGE BROKEN: …` text (`N` is the line it's on in the file). + +Image paths resolve relative to the Markdown file, the same way they do when you +view the file on GitHub, so a page in a subdirectory can share an asset +directory above it: + +``` +docs/ ← needs a markfluence.yaml here for this to work + assets/logo.png + guide/page.md → ![logo](../assets/logo.png) +``` + +That layout needs a [documentation root](../README.md#the-documentation-root) declared at +`docs/` — without one, each page's root defaults to its own directory, and +`guide/page.md` reaching above itself for `assets/` is out of bounds. + +> [!NOTE] +> An image path is a URL, not a filename, so a space or other special character +> has to be percent-encoded — `![shot](assets/my%20image.png)` for a file named +> `my image.png`. This is the same rule GitHub and your editor's preview follow, +> and it is what they produce when they write a link for you. +> +> The angle-bracket form `![shot]()` is an equivalent +> spelling of the same image. A bare space (`![shot](assets/my image.png)`) is +> not a valid path, so it is not an image at all and stays on the page as +> literal text — again matching what GitHub and your preview show. +> +> `markfluence read` and `markfluence export` write the encoded form, so a page +> round-trips back to Markdown that still renders. + +Every image is bounded by the [documentation root](../README.md#the-documentation-root): +one resolving outside it (`../../secrets/x.png`) is reported as +`line N: IMAGE BROKEN: … (outside the documentation root)` rather than +uploaded, and a symlink is refused even when it resolves inside the root. + +Confluence attachment names cannot contain `/`, so an image is attached under +its **base name**: `assets/logo.png` is attached as `logo.png`. The path — +relative to the root, not to the page — is recorded in the attachment's +comment, which is what `markfluence read` and `markfluence export` use to put +the file back where it came from. The same file referenced as +`../assets/logo.png` from a page one directory down is the same attachment, +since both resolve to the same root-relative path. + +Because the name is only the base name, two images in one file whose names +agree — `arch/diagram.png` and `deploy/diagram.png` — cannot both be published: +an attachment name is unique per page, so one would overwrite the other. That is +refused, naming both paths, and `markfluence check` reports it without +publishing. Rename one of the files. + +Extra properties ride in the title as JSON: + +```markdown +![alt](x.png '{"title":"…","width":"100","align":"center"}') +``` + +* `align` is left/center/right; +* `width`/`height` are pixels + +A plain title (`![alt](x.png "tooltip")`) becomes the image tooltip. + +Examples: + +``` +![alt text](./path.png) + +![alt text](https://example.com/image.png) + +![alt text](./path.png "title") + +![alt text](./path.png '{"title":"sometitle","width":100}') +``` + +### Links to other pages + +Links to sibling `.md` files are rewritten to the target page's Confluence +URL; **heading anchors** are rewritten to Confluence's anchor scheme. + +As with image paths, a link destination is a URL: a sibling whose filename has a +space is written `[see](my%20doc.md)` (or `[see]()`), and a bare +`[see](my doc.md)` is not a link at all. The same applies to the fragment, so a +non-ASCII heading anchor may arrive as `#caf%C3%A9-section`. Both are decoded +before markfluence matches them against files and headings on disk, so either +spelling resolves. + +Whether an unresolved link is reported — and how badly — depends on why: + +* A target that **doesn't exist at all**, or **resolves outside the + documentation root**, is Broken: the whole link element is replaced with + literal `line N: LINK BROKEN: … (not found)` or + `line N: LINK BROKEN: … (outside the documentation root)` text, the same + way a broken image already is. +* A target that **exists but has no `page_id` yet** — the normal state of + every page in a tree that hasn't been published — is a Warning + (`link not resolved: …`); the href still renders exactly as written. A + **same-page anchor** (`#heading`) is internally treated as a link to the + *current* file, so it hits this exact warning too when the current file + itself has no `page_id` yet — which reads as though the file names itself + as missing; it doesn't, that's just this file before its first publish. +* A `#fragment` that **matches no heading** on an otherwise-resolvable target + is also a Warning (`anchor not found: …`); the link still works, it just + lands at the top of the page instead of the named heading. + +An attachment link or an external URL was never meant to resolve here and stays +silent either way. A mention is a link too, but a special one — see below. + +### Mentions + +A Confluence mention round-trips as an ordinary markdown link to the person's +profile, with an `@` on the link text: + +```markdown +Ping [@Ada Lovelace](https://home.atlassian.com/people/712020:0e5f8a21-3c4d-4e5f-a6b7-c8d9e0f1a2b3) about the deploy. +``` + +`read` and `export` write that; `create` and `update` publish it back as a real +mention. Three things worth knowing: + +- **The `@` is what makes it a mention.** A link to the same URL whose text does + not start with `@` publishes as a plain link, so you can still link to + somebody's profile without pinging them. +- **The account id is the only durable part.** The display name is regenerated + on every `read`/`export`, so it goes stale harmlessly when somebody changes + their name, and the host is regenerated too — nothing site-specific survives + into the markdown. +- **An id that names nobody is a warning, not an error.** Confluence accepts any + account id and renders it as `@Unlicensed user` rather than failing, so + markfluence looks the id up and says so; nothing else will. + +**A colleague who has left keeps their name.** A deactivated account resolves +normally, and Confluence appends the suffix itself, so a round-tripped page +reads `[@Mark Reid (Deactivated)](…)` and records who has gone rather than +losing them. + +An id that genuinely does not resolve — a typo, a hand-edited URL — renders as +`[@Unlicensed user](…)`, matching what the page itself will show. The id stays +in the URL, so it is still the easiest thing to correct. + +If markfluence cannot *ask* whether an account exists (no network, a rejected +token), the mention is left exactly as it was rather than being given a +placeholder — otherwise one bad moment mid-export would write `Unlicensed user` +over every real name in a tree. + +### Comment directives + +- `` — replaced with Confluence table-of-contents macro. +- `` — replaced with the build stamp, + `markfluence VERSION (SHA, DATE)` (the same string `markfluence --version` + prints). + +### Raw Confluence storage format + +You can paste Confluence +[storage format](https://confluence.atlassian.com/doc/confluence-storage-format-790796544.html) +markup (`` / `` elements — any macro, layout, etc.) straight from a +page's **⋯ → View storage format** into your markdown, and it's emitted verbatim. +Two conventions: + +- **Leave a blank line** between an `ac:`/`ri:` tag and any markdown you want + converted (e.g. a macro or layout-cell body). With a blank line the content is + parsed as markdown; tight against the tags it passes through literally. +- **Put the opening tag on its own line** (or self-close it) so it isn't wrapped in + a paragraph. + +For example, a two-column layout with markdown in each cell: + +``` + + + + +Left column with **markdown**. + + + + +Right column. + + + + +``` + +Storage markup shown inside a fenced code block stays literal (it isn't activated). diff --git a/go.mod b/go.mod index 47372e3..741b0a8 100644 --- a/go.mod +++ b/go.mod @@ -16,14 +16,17 @@ require ( github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.14.0 // indirect ) diff --git a/go.sum b/go.sum index 50ada4b..5af97e9 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,7 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= @@ -28,6 +29,7 @@ github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -39,6 +41,7 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= @@ -47,4 +50,5 @@ golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tools/gendocs/main.go b/tools/gendocs/main.go new file mode 100644 index 0000000..cae6151 --- /dev/null +++ b/tools/gendocs/main.go @@ -0,0 +1,49 @@ +// Command gendocs renders every markfluence command's --help into +// docs/commands/ as markdown, so the command reference is browsable on GitHub +// without installing anything. +// +// The output is generated and checked in, which is a second copy of the help +// text -- deliberately, and only because it cannot drift: `make check` fails +// when the checked-in files disagree with the binary. A hand-maintained copy is +// the thing #102 spent its time removing. +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra/doc" + + "github.com/mozilla/markfluence/cmd" +) + +func main() { + dir := "docs/commands" + if len(os.Args) > 1 { + dir = os.Args[1] + } + if err := os.MkdirAll(dir, 0o755); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + root := cmd.Root() + // No timestamp footer: it would make every regeneration a diff and turn the + // drift check into noise. + root.DisableAutoGenTag = true + // The generated files are the reference; a "completion" page for cobra's + // own generated command is not. + root.InitDefaultCompletionCmd() + for _, c := range root.Commands() { + if c.Name() == "completion" || c.Name() == "help" { + c.Hidden = true + } + } + + if err := doc.GenMarkdownTree(root, dir); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Fprintf(os.Stderr, "wrote command docs to %s\n", filepath.Clean(dir)) +}