From 7399038a960f29e5987fc51caf6ba2e9d7273811 Mon Sep 17 00:00:00 2001 From: pchuri Date: Mon, 5 Oct 2026 21:01:54 +0900 Subject: [PATCH 1/2] feat: map Markdown YAML front matter to content properties - Add opt-in --front-matter to create, create-child, and update (markdown only): the "properties" map is stripped from the body and applied as content properties after the page is saved - Parse and validate front matter before any network call; unlisted properties are left untouched and matching values are skipped, so re-running after a partial failure is safe - Report property failures after the body is saved and exit non-zero; --json includes properties { applied, unchanged, failed } on stdout and a PARTIAL_FAILURE error on stderr - Add read --format markdown --front-matter to export the listed properties that exist, in the given order - Add js-yaml as a direct dependency Closes #260 --- README.md | 38 ++- bin/confluence.js | 125 +++++++- lib/confluence-client.js | 69 +++++ lib/front-matter.js | 152 ++++++++++ package-lock.json | 8 +- package.json | 3 +- plugins/confluence/skills/confluence/SKILL.md | 23 +- tests/confluence-client.test.js | 82 ++++++ tests/front-matter-cli.test.js | 269 ++++++++++++++++++ tests/front-matter.test.js | 126 ++++++++ 10 files changed, 875 insertions(+), 20 deletions(-) create mode 100644 lib/front-matter.js create mode 100644 tests/front-matter-cli.test.js create mode 100644 tests/front-matter.test.js diff --git a/README.md b/README.md index 0f7609f..b2d16e4 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,36 @@ confluence property-delete 123456789 my-key confluence property-delete 123456789 my-key --yes ``` +#### Content properties in Markdown front matter + +Markdown files can carry selected content properties (for example page width) in YAML front matter, so property changes can be reviewed in git and applied through the normal upload workflow. + +```markdown +--- +properties: + content-appearance-published: full-width +--- + +# Page title +``` + +```bash +# Export a page with the listed properties as front matter +# (keys that do not exist on the page are omitted, in the order given) +confluence read 123456789 --format markdown --front-matter content-appearance-published > page.md + +# Upload the body and apply the front matter properties +confluence update 123456789 --file ./page.md --format markdown --front-matter +confluence create "Page title" MYSPACE --file ./page.md --format markdown --front-matter +confluence create-child "Page title" 123456789 --file ./page.md --format markdown --front-matter +``` + +- Front matter is only processed with `--front-matter`; without it, a leading `---` stays part of the Markdown body (a thematic break), as before. +- Only the `properties` map is used; other top-level keys are ignored. Properties not listed are left untouched, and properties whose value already matches are not rewritten. +- Front matter is parsed before anything is uploaded, so invalid YAML fails without changing the page. +- The body is saved first, then the properties. If a property fails (for example because the token lacks the needed scope), the command exits non-zero and reports that the body was saved and which keys failed. With `--json`, the result on stdout includes `properties: { applied, unchanged, failed }` and stderr carries a `PARTIAL_FAILURE` error. Re-running the same command is safe: properties that already match are skipped. +- `read` → edit → `update` round trips keep the selected properties. Markdown output is stable and normalized, not byte-identical to the original file. + ### Comments ```bash # List all comments (footer + inline) @@ -1056,16 +1086,16 @@ confluence stats | Command | Description | Options | |---|---|---| | `init` | Initialize CLI configuration | `--read-only`, `--keychain` | -| `read ` | Read page content | `--format ` | +| `read ` | Read page content | `--format `, `--front-matter ` | | `info ` | Get page information | `--json` | | `search ` | Search for pages | `--json`, `--limit `, `--start ` | | `spaces` | List available spaces | `--json`, `--limit `, `--all` | | `find ` | Find a page by its title | `--space <spaceKey>`, `--json` | | `children <pageId>` | List child pages and folders of a page | `--recursive`, `--max-depth <number>`, `--type <pages\|folders\|all>`, `--format <list\|tree>`, `--json`, `--show-url`, `--show-id` | -| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` | -| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--json` | +| `create <title> <spaceKey>` | Create a new page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--front-matter`, `--json` | +| `create-child <title> <parentId>` | Create a child page or folder | `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--type <page\|folder>`, `--front-matter`, `--json` | | `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet`, `--json` | -| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--json` | +| `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <auto\|storage\|html\|markdown>`, `--front-matter`, `--json` | | `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>`, `--json` | | `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes`, `--json` | | `versions <pageId_or_url>` | List historical versions of a page | `--json` | diff --git a/bin/confluence.js b/bin/confluence.js index 82e64fd..449cfa1 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -20,6 +20,7 @@ const { readStdin } = require('../lib/stdin-utils'); const { emitJson, emitJsonError, jsonRequested, setJsonMode } = require('../lib/output'); const { fingerprintCopyPlan } = require('../lib/pi/copy-plan'); const { VALID_PLANTUML_FORMATS, normalizePlantumlFormat } = require('../lib/plantuml-format'); +const { parseFrontMatter, prependFrontMatter, parsePropertyKeys } = require('../lib/front-matter'); const READ_ONLY_MESSAGE = 'This profile is in read-only mode. Write operations are not allowed.'; const READ_ONLY_TIP = 'Tip: Use "confluence profile add <name>" without --read-only, or set readOnly to false in config.'; @@ -59,6 +60,68 @@ function resolveCliPlantumlFormat(options, { readEnv = false } = {}) { const PLANTUML_FORMAT_OPTION = ['--plantuml-format <format>', `PlantUML macro format (${VALID_PLANTUML_FORMATS.join(', ')})`]; +const FRONT_MATTER_WRITE_OPTION = ['--front-matter', 'Apply the "properties" map from YAML front matter as content properties (requires --format markdown)']; + +class PropertySyncError extends Error { + constructor(message, syncResult) { + super(message); + this.syncResult = syncResult; + } +} + +// With --front-matter, strip the front matter from the body and return its +// properties. Runs before any network call so invalid YAML uploads nothing. +// Without the flag, content is passed through untouched (a leading `---` stays +// a thematic break). +function extractFrontMatter(content, options) { + if (!options.frontMatter) { + return { content, properties: null }; + } + if (String(options.format).trim().toLowerCase() !== 'markdown') { + throw new Error('--front-matter requires --format markdown.'); + } + if (content === null || content === '' || content === undefined) { + throw new Error('--front-matter requires --file or --content.'); + } + const { properties, body } = parseFrontMatter(content); + return { content: body, properties: properties || {} }; +} + +async function applyFrontMatterProperties(client, pageId, properties) { + if (!properties) { + return null; + } + if (Object.keys(properties).length === 0) { + return { applied: [], unchanged: [], failed: [] }; + } + return client.syncProperties(pageId, properties); +} + +function printPropertySummary(syncResult) { + if (!syncResult) return; + if (syncResult.applied.length > 0) { + console.log(`Properties set: ${chalk.blue(syncResult.applied.join(', '))}`); + } + if (syncResult.unchanged.length > 0) { + console.log(`Properties unchanged: ${chalk.gray(syncResult.unchanged.join(', '))}`); + } +} + +// The body is saved before properties, so a property failure is a partial +// result: report it after the success output and exit non-zero. +function assertPropertiesSynced(syncResult, pageId) { + if (!syncResult || syncResult.failed.length === 0) return; + const failures = syncResult.failed + .map(({ key, status, error }) => `${key} (${status ? `${status}: ` : ''}${error})`) + .join(', '); + const count = syncResult.failed.length; + throw new PropertySyncError( + `Page ${pageId} was saved, but ${count} content ${count === 1 ? 'property' : 'properties'} failed: ${failures}. ` + + 'Re-run the same command to retry; properties that already match are skipped.', + syncResult + ); +} + const VALID_TYPES = ['page', 'folder']; function assertValidType(type) { @@ -93,7 +156,11 @@ function handleCommandError(analytics, commandName, error, onExtra = null) { // the stdout=data / stderr=diagnostics contract) so agents/scripts can parse // failures. Non-JSON callers keep the exact human-readable prose below. if (program.opts().json) { - emitJsonError(error); + if (error instanceof PropertySyncError) { + emitJsonError(error, { code: 'PARTIAL_FAILURE', status: null, details: { properties: error.syncResult } }); + } else { + emitJsonError(error); + } process.exit(1); } if (error instanceof ReadOnlyError) { @@ -226,9 +293,23 @@ program .command('read <pageId>') .description('Read a Confluence page by ID or URL') .option('-f, --format <format>', 'Output format (html, text, storage, markdown)', 'text') + .option('--front-matter <keys>', 'Prepend YAML front matter with these comma-separated content property keys (requires --format markdown)') .action(withClient('read', async ({ client, analytics }, pageId, options) => { - const content = await client.readPage(pageId, options.format); - console.log(content); + if (options.frontMatter === undefined) { + const content = await client.readPage(pageId, options.format); + console.log(content); + analytics.track('read', true); + return; + } + + if (String(options.format).trim().toLowerCase() !== 'markdown') { + throw new Error('--front-matter requires --format markdown.'); + } + const keys = parsePropertyKeys(options.frontMatter); + const resolvedId = await client.extractPageId(pageId); + const content = await client.readPage(resolvedId, 'markdown'); + const properties = await client.getPropertyValues(resolvedId, keys); + console.log(prependFrontMatter(properties, content)); analytics.track('read', true); })); @@ -384,6 +465,7 @@ program .option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage') .option('--type <type>', 'Content type (page, folder)', 'page') .option(...PLANTUML_FORMAT_OPTION) + .option(...FRONT_MATTER_WRITE_OPTION) .action(withClient('create', async ({ client, analytics, wantsJson, emitJson }, title, spaceKey, options) => { assertNonEmpty(title, 'title'); assertNonEmpty(spaceKey, 'spaceKey'); @@ -404,8 +486,10 @@ program } else if (options.type !== 'folder') { throw new Error('Either --file or --content option is required'); } + const { content: body, properties } = extractFrontMatter(content, options); - const result = await client.createPage(title, spaceKey, content, options.format, options.type); + const result = await client.createPage(title, spaceKey, body, options.format, options.type); + const syncResult = await applyFrontMatterProperties(client, result.id, properties); if (wantsJson()) { emitJson({ @@ -414,7 +498,9 @@ program type: options.type, spaceKey: result.space.key, url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + ...(syncResult && { properties: syncResult }), }); + assertPropertiesSynced(syncResult, result.id); analytics.track('create', true); return; } @@ -425,6 +511,8 @@ program console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); + printPropertySummary(syncResult); + assertPropertiesSynced(syncResult, result.id); analytics.track('create', true); }, { writable: true })); @@ -438,6 +526,7 @@ program .option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage') .option('--type <type>', 'Content type (page, folder)', 'page') .option(...PLANTUML_FORMAT_OPTION) + .option(...FRONT_MATTER_WRITE_OPTION) .action(withClient('create_child', async ({ client, analytics, wantsJson, emitJson }, title, parentId, options) => { assertNonEmpty(title, 'title'); assertNonEmpty(parentId, 'parentId'); @@ -446,10 +535,6 @@ program const plantumlFormat = resolveCliPlantumlFormat(options); if (plantumlFormat) client.setPlantumlFormat(plantumlFormat); - // Get parent page info to get space key - const parentInfo = await client.getPageInfo(parentId); - const spaceKey = parentInfo.space.key; - let content = ''; if (options.file) { @@ -462,8 +547,14 @@ program } else if (options.type !== 'folder') { throw new Error('Either --file or --content option is required'); } + const { content: body, properties } = extractFrontMatter(content, options); + + // Get parent page info to get space key + const parentInfo = await client.getPageInfo(parentId); + const spaceKey = parentInfo.space.key; - const result = await client.createChildPage(title, spaceKey, parentId, content, options.format, options.type); + const result = await client.createChildPage(title, spaceKey, parentId, body, options.format, options.type); + const syncResult = await applyFrontMatterProperties(client, result.id, properties); if (wantsJson()) { emitJson({ @@ -473,7 +564,9 @@ program parentId, spaceKey: result.space.key, url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + ...(syncResult && { properties: syncResult }), }); + assertPropertiesSynced(syncResult, result.id); analytics.track('create_child', true); return; } @@ -485,6 +578,8 @@ program console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`); console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); + printPropertySummary(syncResult); + assertPropertiesSynced(syncResult, result.id); analytics.track('create_child', true); }, { writable: true })); @@ -498,6 +593,7 @@ program .option('-c, --content <content>', 'Page content as string') .option('--format <format>', 'Content format (auto, storage, html, markdown)', 'storage') .option(...PLANTUML_FORMAT_OPTION) + .option(...FRONT_MATTER_WRITE_OPTION) .action(withClient('update', async ({ client, analytics, wantsJson, emitJson }, pageId, options) => { // Check if at least one option is provided if (!options.title && !options.file && !options.content) { @@ -521,8 +617,13 @@ program } else if (options.content) { content = options.content; } + const { content: body, properties } = extractFrontMatter(content, options); + if (properties && !body.trim()) { + throw new Error('The document has front matter but no body; refusing to replace the page content with an empty body. Use "confluence property-set" to change only properties.'); + } - const result = await client.updatePage(pageId, options.title, content, options.format); + const result = await client.updatePage(pageId, options.title, body, options.format); + const syncResult = await applyFrontMatterProperties(client, result.id, properties); if (wantsJson()) { emitJson({ @@ -530,7 +631,9 @@ program title: result.title, version: result.version.number, url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), + ...(syncResult && { properties: syncResult }), }); + assertPropertiesSynced(syncResult, result.id); analytics.track('update', true); return; } @@ -540,6 +643,8 @@ program console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Version: ${chalk.blue(result.version.number)}`); console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); + printPropertySummary(syncResult); + assertPropertiesSynced(syncResult, result.id); analytics.track('update', true); }, { writable: true })); diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 2be0920..8e065fe 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -2,6 +2,7 @@ const axios = require('axios'); const fs = require('fs'); const https = require('https'); const path = require('path'); +const { isDeepStrictEqual } = require('util'); const FormData = require('form-data'); const { convert } = require('html-to-text'); const { Parser, DomHandler } = require('htmlparser2'); @@ -1616,6 +1617,74 @@ class ConfluenceClient { return { pageId: String(pageId), key }; } + /** + * Apply a map of content properties to a page, one key at a time. + * Keys whose stored value already matches are skipped, so re-running after a + * partial failure only retries what is still different. A failing key does + * not stop the remaining keys; failures are collected instead of thrown. + * Properties not in the map are left untouched. + */ + async syncProperties(pageIdOrUrl, properties) { + const pageId = await this.extractPageId(pageIdOrUrl); + const result = { applied: [], unchanged: [], failed: [] }; + + for (const [key, value] of Object.entries(properties || {})) { + const encodedKey = encodeURIComponent(key); + try { + let version = 1; + try { + const existing = await this.client.get(`/content/${pageId}/property/${encodedKey}`); + if (isDeepStrictEqual(existing.data.value, value)) { + result.unchanged.push(key); + continue; + } + version = existing.data.version.number + 1; + } catch (err) { + if (!err.response || err.response.status !== 404) { + throw err; + } + } + + await this.client.put(`/content/${pageId}/property/${encodedKey}`, { + key, + value, + version: { number: version } + }); + result.applied.push(key); + } catch (error) { + result.failed.push({ + key, + status: error.response?.status ?? null, + error: error.message + }); + } + } + + return result; + } + + /** + * Fetch the listed content properties, in the given order. + * Keys that do not exist on the page are omitted. + */ + async getPropertyValues(pageIdOrUrl, keys) { + const pageId = await this.extractPageId(pageIdOrUrl); + const values = {}; + + for (const key of keys) { + try { + const response = await this.client.get(`/content/${pageId}/property/${encodeURIComponent(key)}`); + values[key] = response.data.value; + } catch (err) { + if (!err.response || err.response.status !== 404) { + throw err; + } + } + } + + return values; + } + markdownToStorage(markdown) { return this.converter.markdownToStorage(markdown); } diff --git a/lib/front-matter.js b/lib/front-matter.js new file mode 100644 index 0000000..0ab80cf --- /dev/null +++ b/lib/front-matter.js @@ -0,0 +1,152 @@ +'use strict'; + +const yaml = require('js-yaml'); + +// YAML front matter that maps to Confluence content properties: +// +// --- +// properties: +// content-appearance-published: full-width +// --- +// +// Only the `properties` map is used; other top-level keys are ignored so the +// same files can carry metadata for other tools. CORE_SCHEMA keeps values to +// plain JSON types (no timestamps or binary), which is what properties store. + +const OPENING = /^\uFEFF?---[ \t]*\r?\n/; +const CLOSING = /^(?:---|\.\.\.)[ \t]*$/; + +class FrontMatterError extends Error {} + +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +// YAML allows .inf/.nan, which JSON (and so a property value) cannot hold. +function assertJsonNumbers(value, key) { + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new FrontMatterError(`Front matter property "${key}" contains a non-finite number.`); + } + if (value !== null && typeof value === 'object') { + Object.values(value).forEach((item) => assertJsonNumbers(item, key)); + } +} + +/** + * Split a Markdown document into its front matter and body. + * Returns `{ properties: null, body }` when the document has no front matter. + * Throws FrontMatterError when the block is unterminated, is not valid YAML, + * or does not have the expected shape. + */ +function parseFrontMatter(text) { + const source = String(text ?? ''); + const opening = source.match(OPENING); + if (!opening) { + return { properties: null, body: source }; + } + + const rest = source.slice(opening[0].length); + const lines = rest.split(/(?<=\n)/); + let offset = 0; + let closingIndex = -1; + for (let i = 0; i < lines.length; i += 1) { + if (CLOSING.test(lines[i].replace(/\r?\n$/, ''))) { + closingIndex = i; + break; + } + offset += lines[i].length; + } + if (closingIndex === -1) { + throw new FrontMatterError('Front matter starts with "---" but has no closing "---" line.'); + } + + const block = rest.slice(0, offset); + const body = rest.slice(offset + lines[closingIndex].length).replace(/^\r?\n/, ''); + + let data; + try { + data = yaml.load(block, { schema: yaml.CORE_SCHEMA }); + } catch (error) { + throw new FrontMatterError(`Invalid YAML in front matter: ${error.reason || error.message}`); + } + + if (data === null || data === undefined) { + return { properties: {}, body }; + } + if (!isPlainObject(data)) { + throw new FrontMatterError('Front matter must be a YAML mapping.'); + } + if (data.properties === undefined || data.properties === null) { + return { properties: {}, body }; + } + if (!isPlainObject(data.properties)) { + throw new FrontMatterError('Front matter "properties" must be a mapping of property keys to values.'); + } + + for (const [key, value] of Object.entries(data.properties)) { + if (!key.trim()) { + throw new FrontMatterError('Front matter property keys cannot be empty.'); + } + // A bare `key:` is more likely a mistake than a request to store null; + // nulls nested inside objects or arrays are kept as-is. + if (value === null) { + throw new FrontMatterError( + `Front matter property "${key}" has no value. Remove the key, or use "confluence property-delete" to delete the property.` + ); + } + assertJsonNumbers(value, key); + } + + return { properties: data.properties, body }; +} + +/** + * Render a front matter block for the given properties, preserving key order. + * Returns an empty string when there is nothing to emit. + */ +function serializeFrontMatter(properties) { + if (!properties || Object.keys(properties).length === 0) { + return ''; + } + // Dump with the default schema so strings that other YAML readers would + // resolve as dates (e.g. 2026-10-05) are quoted. + const block = yaml.dump({ properties }, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + return `---\n${block}---\n\n`; +} + +/** + * Prepend front matter for `properties` to a Markdown body. When there are no + * properties but the body itself starts with `---` (a thematic break), an empty + * block is emitted so a later `--front-matter` upload cannot mistake the body + * for front matter. + */ +function prependFrontMatter(properties, body) { + const block = serializeFrontMatter(properties); + if (block) { + return block + body; + } + return OPENING.test(body) ? `---\nproperties: {}\n---\n\n${body}` : body; +} + +/** + * Parse a comma-separated `--front-matter` key list, trimming and de-duplicating. + */ +function parsePropertyKeys(value) { + const keys = [...new Set(String(value ?? '').split(',').map((key) => key.trim()).filter(Boolean))]; + if (keys.length === 0) { + throw new FrontMatterError('--front-matter requires at least one property key (e.g. --front-matter content-appearance-published).'); + } + return keys; +} + +module.exports = { + FrontMatterError, + parseFrontMatter, + serializeFrontMatter, + prependFrontMatter, + parsePropertyKeys, +}; diff --git a/package-lock.json b/package-lock.json index d925ee7..0ef4de2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "html-to-text": "^9.0.5", "htmlparser2": "^9.1.0", "inquirer": "^8.2.6", + "js-yaml": "^4.3.2", "markdown-it": "^14.3.2" }, "bin": { @@ -4020,10 +4021,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", - "dev": true, + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", diff --git a/package.json b/package.json index 929a074..af0abd0 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "html-to-text": "^9.0.5", "htmlparser2": "^9.1.0", "inquirer": "^8.2.6", + "js-yaml": "^4.3.2", "markdown-it": "^14.3.2" }, "devDependencies": { @@ -59,7 +60,7 @@ ] }, "overrides": { - "js-yaml": "^4.1.1", + "js-yaml": "$js-yaml", "@istanbuljs/load-nyc-config": { "js-yaml": "^3.14.2" } diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 716e662..484e287 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -192,17 +192,19 @@ confluence --profile staging init --domain "staging.example.com" --auth-type bea Read page content. Outputs to stdout. ```sh -confluence read <pageId> [--format html|text|storage|markdown] +confluence read <pageId> [--format html|text|storage|markdown] [--front-matter <keys>] ``` | Option | Default | Description | |---|---|---| | `--format` | `text` | Output format: `html`, `text`, `storage`, or `markdown` | +| `--front-matter` | — | Comma-separated content property keys to prepend as YAML front matter (`--format markdown` only). Missing keys are omitted. | ```sh confluence read 123456789 confluence read 123456789 --format storage confluence read 123456789 --format markdown +confluence read 123456789 --format markdown --front-matter content-appearance-published ``` Markdown output resolves accessible Confluence page links, including links to pages in the same space, to absolute URLs while preserving custom link text and inline formatting. @@ -320,6 +322,7 @@ confluence create <title> <spaceKey> [--content <string>] [--file <path>] [--for | `--file` | — | Path to content file | | `--format` | `storage` | Content format | | `--type` | `page` | Content type — `page` (default) or `folder`. Folders have no body. | +| `--front-matter` | off | Strip YAML front matter from the body and apply its `properties` map as content properties after saving (`--format markdown` only). See [Front matter properties](#front-matter-properties). | Either `--content` or `--file` is required for pages. Folders take no content — passing `--content` or `--file` with `--type folder` is rejected. @@ -365,15 +368,33 @@ confluence update <pageId> [--title <title>] [--content <string>] [--file <path> | `--content` | — | Inline content string | | `--file` | — | Path to content file | | `--format` | `storage` | Content format | +| `--front-matter` | off | Strip YAML front matter from the body and apply its `properties` map as content properties after saving (`--format markdown` only). | ```sh confluence update 123456789 --title "New Title" confluence update 123456789 --file ./updated.md --format markdown +confluence update 123456789 --file ./page.md --format markdown --front-matter confluence update 123456789 --title "New Title" --file ./updated.xml --format storage ``` Title-only updates reuse the target's existing storage body. Folders and other bodyless content cannot be updated this way and return `Page <id> has no readable body (it may be a folder or an unsupported content type).`. +#### Front matter properties + +With `--front-matter`, `create`, `create-child`, and `update` read content properties from YAML front matter: + +```markdown +--- +properties: + content-appearance-published: full-width +--- +``` + +- Without the flag, a leading `---` is treated as Markdown (a thematic break). +- Only `properties` is used; other top-level keys are ignored. Unlisted properties are left untouched; matching values are not rewritten. +- Invalid front matter fails before anything is uploaded. +- The body is saved first. If a property then fails, the command exits 1 and names the failed keys; with `--json`, stdout has `properties: { applied, unchanged, failed }` and stderr has a `PARTIAL_FAILURE` error. Re-running the same command is safe. + --- ### `move <pageId_or_url> <newParentId_or_url>` diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 02e6499..591b707 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -4176,6 +4176,88 @@ describe('ConfluenceClient', () => { mock.restore(); }); + test('syncProperties creates missing keys, updates changed keys, and skips matching keys', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123/property/content-appearance-published').reply(200, { + key: 'content-appearance-published', + value: 'fixed-width', + version: { number: 2 } + }); + mock.onGet('/content/123/property/settings').reply(200, { + key: 'settings', + value: { color: 'red', tags: ['a', 'b'] }, + version: { number: 4 } + }); + mock.onGet('/content/123/property/new-key').reply(404); + mock.onPut(/\/content\/123\/property\/.*/).reply((config) => [200, JSON.parse(config.data)]); + + const result = await client.syncProperties('123', { + 'content-appearance-published': 'full-width', + settings: { color: 'red', tags: ['a', 'b'] }, + 'new-key': true + }); + + expect(result).toEqual({ + applied: ['content-appearance-published', 'new-key'], + unchanged: ['settings'], + failed: [] + }); + const puts = mock.history.put.map((request) => ({ url: request.url, body: JSON.parse(request.data) })); + expect(puts).toEqual([ + { + url: '/content/123/property/content-appearance-published', + body: { key: 'content-appearance-published', value: 'full-width', version: { number: 3 } } + }, + { + url: '/content/123/property/new-key', + body: { key: 'new-key', value: true, version: { number: 1 } } + } + ]); + + mock.restore(); + }); + + test('syncProperties collects failures and keeps applying the remaining keys', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123/property/locked').reply(404); + mock.onPut('/content/123/property/locked').reply(403, { message: 'scope missing' }); + mock.onGet('/content/123/property/open').reply(404); + mock.onPut('/content/123/property/open').reply((config) => [200, JSON.parse(config.data)]); + + const result = await client.syncProperties('123', { locked: 'x', open: 'y' }); + + expect(result.applied).toEqual(['open']); + expect(result.unchanged).toEqual([]); + expect(result.failed).toEqual([ + { key: 'locked', status: 403, error: expect.any(String) } + ]); + + mock.restore(); + }); + + test('getPropertyValues returns existing keys in the requested order and omits missing ones', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123/property/b').reply(200, { key: 'b', value: 2, version: { number: 1 } }); + mock.onGet('/content/123/property/missing').reply(404); + mock.onGet('/content/123/property/a').reply(200, { key: 'a', value: { x: 1 }, version: { number: 1 } }); + + const values = await client.getPropertyValues('123', ['b', 'missing', 'a']); + + expect(values).toEqual({ b: 2, a: { x: 1 } }); + expect(Object.keys(values)).toEqual(['b', 'a']); + + mock.restore(); + }); + + test('getPropertyValues propagates non-404 errors', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123/property/a').reply(403); + + await expect(client.getPropertyValues('123', ['a'])).rejects.toThrow(); + + mock.restore(); + }); + test('getProperty should URL-encode keys with reserved characters', async () => { const mock = new MockAdapter(client.client); mock.onGet('/content/123/property/my%20prop%2Fkey').reply(200, { diff --git a/tests/front-matter-cli.test.js b/tests/front-matter-cli.test.js new file mode 100644 index 0000000..15c9e22 --- /dev/null +++ b/tests/front-matter-cli.test.js @@ -0,0 +1,269 @@ +describe('CLI --front-matter', () => { + const FRONT_MATTER_DOC = [ + '---', + 'properties:', + ' content-appearance-published: full-width', + '---', + '', + '# Title', + '', + ].join('\n'); + + function stripAnsi(value) { + // eslint-disable-next-line no-control-regex + return typeof value === 'string' ? value.replace(/\u001b\[[0-9;]*m/g, '') : value; + } + + function pageResult(overrides = {}) { + return { + id: '555', + title: 'Doc', + space: { key: 'ENG', name: 'Engineering' }, + version: { number: 2 }, + _links: { webui: '/spaces/ENG/pages/555' }, + ...overrides, + }; + } + + async function loadCli(clientOverrides = {}) { + jest.resetModules(); + + const client = { + readPage: jest.fn(async () => '# Title\n'), + getPageInfo: jest.fn(async () => ({ id: '100', title: 'Parent', space: { key: 'ENG', name: 'Engineering' } })), + extractPageId: jest.fn(async (pageId) => String(pageId)), + createPage: jest.fn(async () => pageResult()), + createChildPage: jest.fn(async () => pageResult()), + updatePage: jest.fn(async () => pageResult()), + syncProperties: jest.fn(async (pageId, properties) => ({ + applied: Object.keys(properties), + unchanged: [], + failed: [], + })), + getPropertyValues: jest.fn(async () => ({})), + buildUrl: jest.fn((value) => value), + webUrlPrefix: '/wiki', + ...clientOverrides, + }; + + jest.doMock('../lib/confluence-client', () => jest.fn(() => client)); + jest.doMock('../lib/config', () => ({ + getConfig: jest.fn(() => ({ domain: 'test.atlassian.net', token: 'test-token' })), + initConfig: jest.fn(), + listProfiles: jest.fn(), + setActiveProfile: jest.fn(), + deleteProfile: jest.fn(), + isValidProfileName: jest.fn(() => true), + })); + jest.doMock('../lib/analytics', () => class Analytics { track() {} }); + + let cli; + jest.isolateModules(() => { + cli = require('../bin/confluence.js'); + }); + + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => {}); + + return { + client, + logSpy, + errorSpy, + exitSpy, + run: (args) => cli.program.parseAsync(args, { from: 'user' }), + stdout: () => logSpy.mock.calls.map((call) => stripAnsi(call[0])).join('\n'), + stderr: () => errorSpy.mock.calls.map((call) => call.map(stripAnsi).join(' ')).join('\n'), + }; + } + + afterEach(() => { + jest.restoreAllMocks(); + jest.resetModules(); + }); + + describe('writes', () => { + test('create strips front matter from the body and applies its properties after saving', async () => { + const cli = await loadCli(); + + await cli.run(['--json', 'create', 'Doc', 'ENG', '--content', FRONT_MATTER_DOC, '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).not.toHaveBeenCalled(); + expect(cli.client.createPage).toHaveBeenCalledWith('Doc', 'ENG', '# Title\n', 'markdown', 'page'); + expect(cli.client.syncProperties).toHaveBeenCalledWith('555', { 'content-appearance-published': 'full-width' }); + expect(JSON.parse(cli.logSpy.mock.calls[0][0]).properties).toEqual({ + applied: ['content-appearance-published'], + unchanged: [], + failed: [], + }); + }); + + test('without --front-matter a leading "---" stays part of the body', async () => { + const cli = await loadCli(); + + await cli.run(['create', 'Doc', 'ENG', '--content', FRONT_MATTER_DOC, '--format', 'markdown']); + + expect(cli.client.createPage).toHaveBeenCalledWith('Doc', 'ENG', FRONT_MATTER_DOC, 'markdown', 'page'); + expect(cli.client.syncProperties).not.toHaveBeenCalled(); + }); + + test('a document without front matter is uploaded as-is and sets no properties', async () => { + const cli = await loadCli(); + + await cli.run(['--json', 'update', '555', '--content', '# Title\n', '--format', 'markdown', '--front-matter']); + + expect(cli.client.updatePage).toHaveBeenCalledWith('555', undefined, '# Title\n', 'markdown'); + expect(cli.client.syncProperties).not.toHaveBeenCalled(); + expect(JSON.parse(cli.logSpy.mock.calls[0][0]).properties).toEqual({ applied: [], unchanged: [], failed: [] }); + }); + + test.each([ + ['create', ['create', 'Doc', 'ENG']], + ['create-child', ['create-child', 'Doc', '100']], + ['update', ['update', '555']], + ])('%s rejects invalid YAML before any network call', async (_name, command) => { + const cli = await loadCli(); + + await cli.run([...command, '--content', '---\nproperties: [a\n---\nBody', '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).toHaveBeenCalledWith(1); + expect(cli.stderr()).toMatch(/Invalid YAML in front matter/); + expect(cli.client.getPageInfo).not.toHaveBeenCalled(); + expect(cli.client.createPage).not.toHaveBeenCalled(); + expect(cli.client.createChildPage).not.toHaveBeenCalled(); + expect(cli.client.updatePage).not.toHaveBeenCalled(); + }); + + test('update refuses a document with front matter but no body', async () => { + const cli = await loadCli(); + + await cli.run(['update', '555', '--content', '---\nproperties:\n a: b\n---\n\n', '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).toHaveBeenCalledWith(1); + expect(cli.stderr()).toMatch(/front matter but no body/); + expect(cli.client.updatePage).not.toHaveBeenCalled(); + }); + + test('requires --format markdown', async () => { + const cli = await loadCli(); + + await cli.run(['update', '555', '--content', FRONT_MATTER_DOC, '--front-matter']); + + expect(cli.exitSpy).toHaveBeenCalledWith(1); + expect(cli.stderr()).toMatch(/--front-matter requires --format markdown/); + expect(cli.client.updatePage).not.toHaveBeenCalled(); + }); + + test('requires body content', async () => { + const cli = await loadCli(); + + await cli.run(['update', '555', '--title', 'New', '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).toHaveBeenCalledWith(1); + expect(cli.stderr()).toMatch(/--front-matter requires --file or --content/); + expect(cli.client.updatePage).not.toHaveBeenCalled(); + }); + + test('create-child applies properties to the new child page', async () => { + const cli = await loadCli(); + + await cli.run(['create-child', 'Doc', '100', '--content', FRONT_MATTER_DOC, '--format', 'markdown', '--front-matter']); + + expect(cli.client.createChildPage).toHaveBeenCalledWith('Doc', 'ENG', '100', '# Title\n', 'markdown', 'page'); + expect(cli.client.syncProperties).toHaveBeenCalledWith('555', { 'content-appearance-published': 'full-width' }); + expect(cli.stdout()).toContain('Properties set: content-appearance-published'); + }); + + test('text output reports properties that already match', async () => { + const cli = await loadCli({ + syncProperties: jest.fn(async () => ({ applied: [], unchanged: ['content-appearance-published'], failed: [] })), + }); + + await cli.run(['update', '555', '--content', FRONT_MATTER_DOC, '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).not.toHaveBeenCalled(); + expect(cli.stdout()).toContain('Properties unchanged: content-appearance-published'); + }); + + test('partial failure in text mode reports the saved body and failed keys, then exits non-zero', async () => { + const cli = await loadCli({ + syncProperties: jest.fn(async () => ({ + applied: ['a'], + unchanged: [], + failed: [{ key: 'content-appearance-published', status: 403, error: 'Request failed with status code 403' }], + })), + }); + + await cli.run(['update', '555', '--content', FRONT_MATTER_DOC, '--format', 'markdown', '--front-matter']); + + expect(cli.stdout()).toContain('Page updated successfully!'); + expect(cli.stdout()).toContain('Properties set: a'); + expect(cli.stderr()).toContain('Page 555 was saved, but 1 content property failed: content-appearance-published (403: Request failed with status code 403)'); + expect(cli.stderr()).toContain('Re-run the same command to retry'); + expect(cli.exitSpy).toHaveBeenCalledWith(1); + }); + + test('partial failure in --json mode prints the result on stdout and a PARTIAL_FAILURE error on stderr', async () => { + const syncResult = { + applied: [], + unchanged: [], + failed: [{ key: 'content-appearance-published', status: 403, error: 'Request failed with status code 403' }], + }; + const cli = await loadCli({ syncProperties: jest.fn(async () => syncResult) }); + + await cli.run(['--json', 'update', '555', '--content', FRONT_MATTER_DOC, '--format', 'markdown', '--front-matter']); + + expect(JSON.parse(cli.logSpy.mock.calls[0][0])).toMatchObject({ id: '555', version: 2, properties: syncResult }); + const error = JSON.parse(cli.errorSpy.mock.calls[0][0]); + expect(error).toMatchObject({ code: 'PARTIAL_FAILURE', status: null, details: { properties: syncResult } }); + expect(error.error).toMatch(/Page 555 was saved/); + expect(cli.exitSpy).toHaveBeenCalledWith(1); + }); + }); + + describe('read', () => { + test('prepends the listed properties that exist, in the given order', async () => { + const cli = await loadCli({ + getPropertyValues: jest.fn(async () => ({ 'content-appearance-published': 'full-width' })), + }); + + await cli.run(['read', '555', '--format', 'markdown', '--front-matter', 'content-appearance-published, missing']); + + expect(cli.client.readPage).toHaveBeenCalledWith('555', 'markdown'); + expect(cli.client.getPropertyValues).toHaveBeenCalledWith('555', ['content-appearance-published', 'missing']); + expect(cli.logSpy.mock.calls[0][0]).toBe(FRONT_MATTER_DOC); + }); + + test('emits no front matter when none of the keys exist', async () => { + const cli = await loadCli(); + + await cli.run(['read', '555', '--format', 'markdown', '--front-matter', 'missing']); + + expect(cli.logSpy.mock.calls[0][0]).toBe('# Title\n'); + }); + + test('requires --format markdown', async () => { + const cli = await loadCli(); + + await cli.run(['read', '555', '--front-matter', 'content-appearance-published']); + + expect(cli.exitSpy).toHaveBeenCalledWith(1); + expect(cli.stderr()).toMatch(/--front-matter requires --format markdown/); + expect(cli.client.readPage).not.toHaveBeenCalled(); + }); + + test('read output round-trips through update --front-matter', async () => { + const properties = { 'content-appearance-published': 'full-width', settings: { tags: ['a'], note: null } }; + const reader = await loadCli({ getPropertyValues: jest.fn(async () => properties) }); + await reader.run(['read', '555', '--format', 'markdown', '--front-matter', 'content-appearance-published,settings']); + const exported = reader.logSpy.mock.calls[0][0]; + jest.restoreAllMocks(); + + const writer = await loadCli(); + await writer.run(['update', '555', '--content', exported, '--format', 'markdown', '--front-matter']); + + expect(writer.client.updatePage).toHaveBeenCalledWith('555', undefined, '# Title\n', 'markdown'); + expect(writer.client.syncProperties).toHaveBeenCalledWith('555', properties); + }); + }); +}); diff --git a/tests/front-matter.test.js b/tests/front-matter.test.js new file mode 100644 index 0000000..c35a434 --- /dev/null +++ b/tests/front-matter.test.js @@ -0,0 +1,126 @@ +const { + FrontMatterError, + parseFrontMatter, + serializeFrontMatter, + prependFrontMatter, + parsePropertyKeys, +} = require('../lib/front-matter'); + +describe('parseFrontMatter', () => { + test('returns the body untouched when there is no front matter', () => { + const text = '# Title\n\nBody\n'; + expect(parseFrontMatter(text)).toEqual({ properties: null, body: text }); + }); + + test('extracts the properties map and strips the block from the body', () => { + const text = [ + '---', + 'properties:', + ' content-appearance-published: full-width', + ' settings:', + ' color: red', + ' tags: [a, b]', + '---', + '', + '# Title', + '', + ].join('\n'); + + expect(parseFrontMatter(text)).toEqual({ + properties: { + 'content-appearance-published': 'full-width', + settings: { color: 'red', tags: ['a', 'b'] }, + }, + body: '# Title\n', + }); + }); + + test('handles CRLF line endings, a BOM, and a "..." closing line', () => { + const text = '\uFEFF---\r\nproperties:\r\n width: full-width\r\n...\r\n\r\nBody\r\n'; + expect(parseFrontMatter(text)).toEqual({ + properties: { width: 'full-width' }, + body: 'Body\r\n', + }); + }); + + test('ignores other top-level keys', () => { + const text = '---\ntitle: Ignored\ntags: [x]\nproperties:\n a: 1\n---\nBody'; + expect(parseFrontMatter(text)).toEqual({ properties: { a: 1 }, body: 'Body' }); + }); + + test('returns an empty map when front matter has no properties', () => { + expect(parseFrontMatter('---\ntitle: x\n---\nBody')).toEqual({ properties: {}, body: 'Body' }); + expect(parseFrontMatter('---\n---\nBody')).toEqual({ properties: {}, body: 'Body' }); + }); + + test('keeps a later thematic break in the body', () => { + const text = '---\nproperties:\n a: b\n---\nIntro\n\n---\n\nMore\n'; + expect(parseFrontMatter(text).body).toBe('Intro\n\n---\n\nMore\n'); + }); + + test('keeps nulls nested inside a value', () => { + const { properties } = parseFrontMatter('---\nproperties:\n a:\n b: null\n c: [1, null]\n---\n'); + expect(properties).toEqual({ a: { b: null, c: [1, null] } }); + }); + + test('does not convert dates into Date objects', () => { + const { properties } = parseFrontMatter('---\nproperties:\n reviewed: 2026-10-05\n---\n'); + expect(properties.reviewed).toBe('2026-10-05'); + }); + + test.each([ + ['unterminated block', '---\nproperties:\n a: b\n', /no closing/], + ['invalid YAML', '---\nproperties: [a\n---\n', /Invalid YAML/], + ['non-mapping root', '---\n- a\n- b\n---\n', /must be a YAML mapping/], + ['non-mapping properties', '---\nproperties: [a, b]\n---\n', /"properties" must be a mapping/], + ['null value', '---\nproperties:\n a:\n---\n', /has no value/], + ['non-finite number', '---\nproperties:\n a: [1, .inf]\n---\n', /non-finite number/], + ])('rejects %s', (_label, text, message) => { + expect(() => parseFrontMatter(text)).toThrow(FrontMatterError); + expect(() => parseFrontMatter(text)).toThrow(message); + }); +}); + +describe('serializeFrontMatter', () => { + test('returns an empty string when there are no properties', () => { + expect(serializeFrontMatter({})).toBe(''); + expect(serializeFrontMatter(null)).toBe(''); + }); + + test('renders properties in insertion order and round-trips through the parser', () => { + const properties = { + 'content-appearance-published': 'full-width', + settings: { color: 'red', count: 3, enabled: true, tags: ['a', 'b'] }, + reviewed: '2026-10-05', + }; + const block = serializeFrontMatter(properties); + + expect(block.startsWith('---\nproperties:\n content-appearance-published: full-width\n')).toBe(true); + expect(block.endsWith('---\n\n')).toBe(true); + expect(parseFrontMatter(`${block}# Body\n`)).toEqual({ properties, body: '# Body\n' }); + }); +}); + +describe('prependFrontMatter', () => { + test('leaves the body alone when there are no properties', () => { + expect(prependFrontMatter({}, '# Title\n')).toBe('# Title\n'); + }); + + test('emits an empty block when the body starts with a thematic break', () => { + const body = '---\n\nNote: kept\n\n---\n'; + const output = prependFrontMatter({}, body); + + expect(output).toBe(`---\nproperties: {}\n---\n\n${body}`); + expect(parseFrontMatter(output)).toEqual({ properties: {}, body }); + }); +}); + +describe('parsePropertyKeys', () => { + test('splits, trims, and de-duplicates keys', () => { + expect(parsePropertyKeys(' a, b ,a,,c ')).toEqual(['a', 'b', 'c']); + }); + + test('rejects an empty list', () => { + expect(() => parsePropertyKeys(' , ')).toThrow(/at least one property key/); + }); +}); From b87c6c0a00a3c863ae8999f2a93b9023eada8030 Mon Sep 17 00:00:00 2001 From: pchuri <pchuri@gmail.com> Date: Mon, 5 Oct 2026 21:38:31 +0900 Subject: [PATCH 2/2] fix: address review feedback on front matter properties - Point create/create-child partial failures at the update command that retries the properties, instead of re-running create - Include the Confluence error message in failed property entries - Reject --front-matter with --type folder, accept an empty file, and only refuse an empty body when the document actually has front matter - Warn when front matter has no "properties" map - Keep the requested key order on read for integer-like keys - Document content-appearance-draft alongside the published key --- README.md | 5 +- bin/confluence.js | 44 ++++++++----- lib/confluence-client.js | 10 +-- lib/front-matter.js | 26 ++++---- plugins/confluence/skills/confluence/SKILL.md | 5 +- tests/confluence-client.test.js | 5 +- tests/front-matter-cli.test.js | 64 ++++++++++++++++++- tests/front-matter.test.js | 23 +++++-- 8 files changed, 133 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index b2d16e4..baeedcd 100644 --- a/README.md +++ b/README.md @@ -713,9 +713,10 @@ confluence create-child "Page title" 123456789 --file ./page.md --format markdow ``` - Front matter is only processed with `--front-matter`; without it, a leading `---` stays part of the Markdown body (a thematic break), as before. -- Only the `properties` map is used; other top-level keys are ignored. Properties not listed are left untouched, and properties whose value already matches are not rewritten. +- Only the `properties` map is used; other top-level keys are ignored (a warning is printed if the block has no `properties` map). Properties not listed are left untouched, and properties whose value already matches are not rewritten. +- On Confluence Cloud the editor also keeps a `content-appearance-draft` property; set it alongside `content-appearance-published` so the width survives the next edit in the browser. - Front matter is parsed before anything is uploaded, so invalid YAML fails without changing the page. -- The body is saved first, then the properties. If a property fails (for example because the token lacks the needed scope), the command exits non-zero and reports that the body was saved and which keys failed. With `--json`, the result on stdout includes `properties: { applied, unchanged, failed }` and stderr carries a `PARTIAL_FAILURE` error. Re-running the same command is safe: properties that already match are skipped. +- The body is saved first, then the properties. If a property fails (for example because the token lacks the needed scope), the command exits non-zero and reports that the body was saved and which keys failed. With `--json`, the result on stdout includes `properties: { applied, unchanged, failed }` and stderr carries a `PARTIAL_FAILURE` error. Re-running `update` is safe: properties that already match are skipped. For `create` and `create-child`, the error names the `update` command that retries the properties on the new page (re-running `create` would try to make another page). - `read` → edit → `update` round trips keep the selected properties. Markdown output is stable and normalized, not byte-identical to the original file. ### Comments diff --git a/bin/confluence.js b/bin/confluence.js index 449cfa1..178a46f 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -75,16 +75,22 @@ class PropertySyncError extends Error { // a thematic break). function extractFrontMatter(content, options) { if (!options.frontMatter) { - return { content, properties: null }; + return { content, properties: null, present: false }; + } + if (options.type === 'folder') { + throw new Error('--front-matter is not allowed with --type folder (folders have no body).'); } if (String(options.format).trim().toLowerCase() !== 'markdown') { throw new Error('--front-matter requires --format markdown.'); } - if (content === null || content === '' || content === undefined) { + if (content === null || content === undefined) { throw new Error('--front-matter requires --file or --content.'); } - const { properties, body } = parseFrontMatter(content); - return { content: body, properties: properties || {} }; + const { properties, body, present } = parseFrontMatter(content); + if (present && !properties) { + console.error(chalk.yellow('Warning: front matter has no "properties" map; no content properties will be set.')); + } + return { content: body, properties: properties || {}, present }; } async function applyFrontMatterProperties(client, pageId, properties) { @@ -107,17 +113,25 @@ function printPropertySummary(syncResult) { } } +// Re-running create would make another page, so point at update instead. +function createRetryHint(pageId, options) { + const source = options.file ? `--file ${options.file}` : '--file <file>'; + return `Retry the properties with "confluence update ${pageId} ${source} --format markdown --front-matter"; ` + + 'properties that already match are skipped.'; +} + +const UPDATE_RETRY_HINT = 'Re-run the same command to retry; properties that already match are skipped.'; + // The body is saved before properties, so a property failure is a partial // result: report it after the success output and exit non-zero. -function assertPropertiesSynced(syncResult, pageId) { +function assertPropertiesSynced(syncResult, pageId, retryHint) { if (!syncResult || syncResult.failed.length === 0) return; const failures = syncResult.failed .map(({ key, status, error }) => `${key} (${status ? `${status}: ` : ''}${error})`) .join(', '); const count = syncResult.failed.length; throw new PropertySyncError( - `Page ${pageId} was saved, but ${count} content ${count === 1 ? 'property' : 'properties'} failed: ${failures}. ` + - 'Re-run the same command to retry; properties that already match are skipped.', + `Page ${pageId} was saved, but ${count} content ${count === 1 ? 'property' : 'properties'} failed: ${failures}. ${retryHint}`, syncResult ); } @@ -500,7 +514,7 @@ program url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), ...(syncResult && { properties: syncResult }), }); - assertPropertiesSynced(syncResult, result.id); + assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options)); analytics.track('create', true); return; } @@ -512,7 +526,7 @@ program console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); printPropertySummary(syncResult); - assertPropertiesSynced(syncResult, result.id); + assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options)); analytics.track('create', true); }, { writable: true })); @@ -566,7 +580,7 @@ program url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), ...(syncResult && { properties: syncResult }), }); - assertPropertiesSynced(syncResult, result.id); + assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options)); analytics.track('create_child', true); return; } @@ -579,7 +593,7 @@ program console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); printPropertySummary(syncResult); - assertPropertiesSynced(syncResult, result.id); + assertPropertiesSynced(syncResult, result.id, createRetryHint(result.id, options)); analytics.track('create_child', true); }, { writable: true })); @@ -617,8 +631,8 @@ program } else if (options.content) { content = options.content; } - const { content: body, properties } = extractFrontMatter(content, options); - if (properties && !body.trim()) { + const { content: body, properties, present } = extractFrontMatter(content, options); + if (present && !body.trim()) { throw new Error('The document has front matter but no body; refusing to replace the page content with an empty body. Use "confluence property-set" to change only properties.'); } @@ -633,7 +647,7 @@ program url: client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`), ...(syncResult && { properties: syncResult }), }); - assertPropertiesSynced(syncResult, result.id); + assertPropertiesSynced(syncResult, result.id, UPDATE_RETRY_HINT); analytics.track('update', true); return; } @@ -644,7 +658,7 @@ program console.log(`Version: ${chalk.blue(result.version.number)}`); console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); printPropertySummary(syncResult); - assertPropertiesSynced(syncResult, result.id); + assertPropertiesSynced(syncResult, result.id, UPDATE_RETRY_HINT); analytics.track('update', true); }, { writable: true })); diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 8e065fe..9138668 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -1655,7 +1655,7 @@ class ConfluenceClient { result.failed.push({ key, status: error.response?.status ?? null, - error: error.message + error: error.response?.data?.message || error.message }); } } @@ -1664,17 +1664,17 @@ class ConfluenceClient { } /** - * Fetch the listed content properties, in the given order. - * Keys that do not exist on the page are omitted. + * Fetch the listed content properties as `[key, value]` entries, in the + * given order. Keys that do not exist on the page are omitted. */ async getPropertyValues(pageIdOrUrl, keys) { const pageId = await this.extractPageId(pageIdOrUrl); - const values = {}; + const values = []; for (const key of keys) { try { const response = await this.client.get(`/content/${pageId}/property/${encodeURIComponent(key)}`); - values[key] = response.data.value; + values.push([key, response.data.value]); } catch (err) { if (!err.response || err.response.status !== 404) { throw err; diff --git a/lib/front-matter.js b/lib/front-matter.js index 0ab80cf..564d28a 100644 --- a/lib/front-matter.js +++ b/lib/front-matter.js @@ -34,7 +34,8 @@ function assertJsonNumbers(value, key) { /** * Split a Markdown document into its front matter and body. - * Returns `{ properties: null, body }` when the document has no front matter. + * `present` says whether the document has a front matter block; `properties` + * is null when there is no block or the block has no `properties` key. * Throws FrontMatterError when the block is unterminated, is not valid YAML, * or does not have the expected shape. */ @@ -42,7 +43,7 @@ function parseFrontMatter(text) { const source = String(text ?? ''); const opening = source.match(OPENING); if (!opening) { - return { properties: null, body: source }; + return { properties: null, body: source, present: false }; } const rest = source.slice(opening[0].length); @@ -71,13 +72,13 @@ function parseFrontMatter(text) { } if (data === null || data === undefined) { - return { properties: {}, body }; + return { properties: null, body, present: true }; } if (!isPlainObject(data)) { throw new FrontMatterError('Front matter must be a YAML mapping.'); } if (data.properties === undefined || data.properties === null) { - return { properties: {}, body }; + return { properties: null, body, present: true }; } if (!isPlainObject(data.properties)) { throw new FrontMatterError('Front matter "properties" must be a mapping of property keys to values.'); @@ -97,25 +98,26 @@ function parseFrontMatter(text) { assertJsonNumbers(value, key); } - return { properties: data.properties, body }; + return { properties: data.properties, body, present: true }; } /** * Render a front matter block for the given properties, preserving key order. + * Accepts `[key, value]` entries (or a plain object). Entries are dumped one at + * a time because a plain object would move integer-like keys to the front. * Returns an empty string when there is nothing to emit. */ function serializeFrontMatter(properties) { - if (!properties || Object.keys(properties).length === 0) { + const entries = Array.isArray(properties) ? properties : Object.entries(properties || {}); + if (entries.length === 0) { return ''; } // Dump with the default schema so strings that other YAML readers would // resolve as dates (e.g. 2026-10-05) are quoted. - const block = yaml.dump({ properties }, { - lineWidth: -1, - noRefs: true, - sortKeys: false, - }); - return `---\n${block}---\n\n`; + const block = entries + .map(([key, value]) => yaml.dump({ [key]: value }, { lineWidth: -1, noRefs: true }).replace(/^(?=.)/gm, ' ')) + .join(''); + return `---\nproperties:\n${block}---\n\n`; } /** diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index 484e287..3c26ccb 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -391,9 +391,10 @@ properties: ``` - Without the flag, a leading `---` is treated as Markdown (a thematic break). -- Only `properties` is used; other top-level keys are ignored. Unlisted properties are left untouched; matching values are not rewritten. +- Only `properties` is used; other top-level keys are ignored (with a warning if `properties` is missing). Unlisted properties are left untouched; matching values are not rewritten. Not allowed with `--type folder`. +- On Cloud, set `content-appearance-draft` alongside `content-appearance-published` so page width survives the next browser edit. - Invalid front matter fails before anything is uploaded. -- The body is saved first. If a property then fails, the command exits 1 and names the failed keys; with `--json`, stdout has `properties: { applied, unchanged, failed }` and stderr has a `PARTIAL_FAILURE` error. Re-running the same command is safe. +- The body is saved first. If a property then fails, the command exits 1 and names the failed keys; with `--json`, stdout has `properties: { applied, unchanged, failed }` and stderr has a `PARTIAL_FAILURE` error. Re-running `update` is safe; after a `create`/`create-child` partial failure, run the `update ... --front-matter` command the error suggests instead of re-running `create`. --- diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 591b707..0b0869f 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -4229,7 +4229,7 @@ describe('ConfluenceClient', () => { expect(result.applied).toEqual(['open']); expect(result.unchanged).toEqual([]); expect(result.failed).toEqual([ - { key: 'locked', status: 403, error: expect.any(String) } + { key: 'locked', status: 403, error: 'scope missing' } ]); mock.restore(); @@ -4243,8 +4243,7 @@ describe('ConfluenceClient', () => { const values = await client.getPropertyValues('123', ['b', 'missing', 'a']); - expect(values).toEqual({ b: 2, a: { x: 1 } }); - expect(Object.keys(values)).toEqual(['b', 'a']); + expect(values).toEqual([['b', 2], ['a', { x: 1 }]]); mock.restore(); }); diff --git a/tests/front-matter-cli.test.js b/tests/front-matter-cli.test.js index 15c9e22..9e4aa21 100644 --- a/tests/front-matter-cli.test.js +++ b/tests/front-matter-cli.test.js @@ -1,3 +1,7 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + describe('CLI --front-matter', () => { const FRONT_MATTER_DOC = [ '---', @@ -40,7 +44,7 @@ describe('CLI --front-matter', () => { unchanged: [], failed: [], })), - getPropertyValues: jest.fn(async () => ({})), + getPropertyValues: jest.fn(async () => []), buildUrl: jest.fn((value) => value), webUrlPrefix: '/wiki', ...clientOverrides, @@ -154,6 +158,60 @@ describe('CLI --front-matter', () => { expect(cli.client.updatePage).not.toHaveBeenCalled(); }); + test('create rejects --front-matter with --type folder', async () => { + const cli = await loadCli(); + + await cli.run(['create', 'Docs', 'ENG', '--type', 'folder', '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).toHaveBeenCalledWith(1); + expect(cli.stderr()).toMatch(/--front-matter is not allowed with --type folder/); + expect(cli.client.createPage).not.toHaveBeenCalled(); + }); + + test('warns when front matter has no properties map', async () => { + const cli = await loadCli(); + + await cli.run(['update', '555', '--content', '---\ntitle: x\n---\n# Title\n', '--format', 'markdown', '--front-matter']); + + expect(cli.stderr()).toContain('Warning: front matter has no "properties" map'); + expect(cli.client.updatePage).toHaveBeenCalledWith('555', undefined, '# Title\n', 'markdown'); + expect(cli.client.syncProperties).not.toHaveBeenCalled(); + }); + + test('update with blank content and no front matter keeps the existing behavior', async () => { + const cli = await loadCli(); + + await cli.run(['update', '555', '--content', ' ', '--format', 'markdown', '--front-matter']); + + expect(cli.exitSpy).not.toHaveBeenCalled(); + expect(cli.client.updatePage).toHaveBeenCalledWith('555', undefined, ' ', 'markdown'); + }); + + test('create partial failure points at update instead of re-running create', async () => { + const cli = await loadCli({ + syncProperties: jest.fn(async () => ({ + applied: [], + unchanged: [], + failed: [{ key: 'content-appearance-published', status: 403, error: 'scope missing' }], + })), + }); + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'front-matter-')); + const file = path.join(dir, 'page.md'); + fs.writeFileSync(file, FRONT_MATTER_DOC); + try { + await cli.run(['create', 'Doc', 'ENG', '--file', file, '--format', 'markdown', '--front-matter']); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + + expect(cli.stdout()).toContain('Page created successfully!'); + expect(cli.stderr()).toContain('Page 555 was saved, but 1 content property failed: content-appearance-published (403: scope missing)'); + expect(cli.stderr()).toContain(`Retry the properties with "confluence update 555 --file ${file} --format markdown --front-matter"`); + expect(cli.stderr()).not.toContain('Re-run the same command'); + expect(cli.exitSpy).toHaveBeenCalledWith(1); + }); + test('requires body content', async () => { const cli = await loadCli(); @@ -224,7 +282,7 @@ describe('CLI --front-matter', () => { describe('read', () => { test('prepends the listed properties that exist, in the given order', async () => { const cli = await loadCli({ - getPropertyValues: jest.fn(async () => ({ 'content-appearance-published': 'full-width' })), + getPropertyValues: jest.fn(async () => [['content-appearance-published', 'full-width']]), }); await cli.run(['read', '555', '--format', 'markdown', '--front-matter', 'content-appearance-published, missing']); @@ -254,7 +312,7 @@ describe('CLI --front-matter', () => { test('read output round-trips through update --front-matter', async () => { const properties = { 'content-appearance-published': 'full-width', settings: { tags: ['a'], note: null } }; - const reader = await loadCli({ getPropertyValues: jest.fn(async () => properties) }); + const reader = await loadCli({ getPropertyValues: jest.fn(async () => Object.entries(properties)) }); await reader.run(['read', '555', '--format', 'markdown', '--front-matter', 'content-appearance-published,settings']); const exported = reader.logSpy.mock.calls[0][0]; jest.restoreAllMocks(); diff --git a/tests/front-matter.test.js b/tests/front-matter.test.js index c35a434..0174eca 100644 --- a/tests/front-matter.test.js +++ b/tests/front-matter.test.js @@ -9,7 +9,7 @@ const { describe('parseFrontMatter', () => { test('returns the body untouched when there is no front matter', () => { const text = '# Title\n\nBody\n'; - expect(parseFrontMatter(text)).toEqual({ properties: null, body: text }); + expect(parseFrontMatter(text)).toEqual({ properties: null, body: text, present: false }); }); test('extracts the properties map and strips the block from the body', () => { @@ -32,6 +32,7 @@ describe('parseFrontMatter', () => { settings: { color: 'red', tags: ['a', 'b'] }, }, body: '# Title\n', + present: true, }); }); @@ -40,17 +41,18 @@ describe('parseFrontMatter', () => { expect(parseFrontMatter(text)).toEqual({ properties: { width: 'full-width' }, body: 'Body\r\n', + present: true, }); }); test('ignores other top-level keys', () => { const text = '---\ntitle: Ignored\ntags: [x]\nproperties:\n a: 1\n---\nBody'; - expect(parseFrontMatter(text)).toEqual({ properties: { a: 1 }, body: 'Body' }); + expect(parseFrontMatter(text)).toEqual({ properties: { a: 1 }, body: 'Body', present: true }); }); - test('returns an empty map when front matter has no properties', () => { - expect(parseFrontMatter('---\ntitle: x\n---\nBody')).toEqual({ properties: {}, body: 'Body' }); - expect(parseFrontMatter('---\n---\nBody')).toEqual({ properties: {}, body: 'Body' }); + test('reports a block without properties as present with null properties', () => { + expect(parseFrontMatter('---\ntitle: x\n---\nBody')).toEqual({ properties: null, body: 'Body', present: true }); + expect(parseFrontMatter('---\n---\nBody')).toEqual({ properties: null, body: 'Body', present: true }); }); test('keeps a later thematic break in the body', () => { @@ -97,7 +99,14 @@ describe('serializeFrontMatter', () => { expect(block.startsWith('---\nproperties:\n content-appearance-published: full-width\n')).toBe(true); expect(block.endsWith('---\n\n')).toBe(true); - expect(parseFrontMatter(`${block}# Body\n`)).toEqual({ properties, body: '# Body\n' }); + expect(parseFrontMatter(`${block}# Body\n`)).toEqual({ properties, body: '# Body\n', present: true }); + }); + + test('keeps the given order for integer-like keys when given entries', () => { + const block = serializeFrontMatter([['b', 1], ['123', 'x'], ['a', { nested: ['y'] }]]); + + expect(block).toBe('---\nproperties:\n b: 1\n \'123\': x\n a:\n nested:\n - \'y\'\n---\n\n'); + expect(parseFrontMatter(block).properties).toEqual({ b: 1, 123: 'x', a: { nested: ['y'] } }); }); }); @@ -111,7 +120,7 @@ describe('prependFrontMatter', () => { const output = prependFrontMatter({}, body); expect(output).toBe(`---\nproperties: {}\n---\n\n${body}`); - expect(parseFrontMatter(output)).toEqual({ properties: {}, body }); + expect(parseFrontMatter(output)).toEqual({ properties: {}, body, present: true }); }); });