From 2a1ed09eb6c93f48edb593d924369f0718c87bc5 Mon Sep 17 00:00:00 2001 From: Kirill Shirinkin Date: Wed, 9 Sep 2026 15:50:27 +0200 Subject: [PATCH] fix: preserve blank lines between Basecamp paragraphs --- CHANGELOG.md | 5 +++++ README.md | 1 + manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- src/render.ts | 6 ++++-- tests/core.test.ts | 36 +++++++++++++++++++++++++++++++++++- versions.json | 2 +- 8 files changed, 50 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2d5ec7..17dde1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.1.7 + +- Preserve visible blank lines between paragraphs in Basecamp, including long notes, blockquotes and paragraphs within list items. +- Apply the spacing fix to existing linked documents on their next sync, even when their note text is unchanged. Document identity and remote-edit conflict checks are preserved. + ## 0.1.6 - Prepare the first public release with account, network and hosted-service disclosures. diff --git a/README.md b/README.md index a931e05..9a40c1d 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Copying a note also copies its identity. Remove `basecamp_sync` from the **copy* | Obsidian content | Basecamp result | | --- | --- | +| Paragraphs and line breaks | Blank lines between paragraphs; single newlines remain line breaks | | Headings | Basecamp heading style; heading levels flatten because its documented HTML subset only includes `h1` | | Bold, italic, strike, lists, quotes | Native rich text | | Task lists | Readable checked/unchecked symbols; not Basecamp to-dos | diff --git a/manifest.json b/manifest.json index 5ec0cde..359a052 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "basecamp-sync", "name": "Basecamp Sync", - "version": "0.1.6", + "version": "0.1.7", "minAppVersion": "1.11.4", "description": "Sync selected notes and folders to formatted Basecamp documents.", "author": "mkdev", diff --git a/package-lock.json b/package-lock.json index 1599031..929f2f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "basecamp-sync", - "version": "0.1.6", + "version": "0.1.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "basecamp-sync", - "version": "0.1.6", + "version": "0.1.7", "license": "MIT", "dependencies": { "@37signals/basecamp": "0.16.0", diff --git a/package.json b/package.json index 4329453..cb2d4f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "basecamp-sync", - "version": "0.1.6", + "version": "0.1.7", "description": "Sync selected Obsidian notes to Basecamp documents.", "private": true, "type": "module", diff --git a/src/render.ts b/src/render.ts index d5ae360..14ac89f 100644 --- a/src/render.ts +++ b/src/render.ts @@ -45,7 +45,9 @@ export async function renderNote(markdown: string, options: RenderOptions): Prom return true; }); md.renderer.rules.paragraph_open = () => '
'; - md.renderer.rules.paragraph_close = () => '
\n'; + // Basecamp gives divs no paragraph margins, so blank lines must be explicit. + md.renderer.rules.paragraph_close = (tokens, index) => + tokens[index + 1]?.type === 'paragraph_open' ? '\n

\n' : '\n'; md.renderer.rules.heading_open = () => '

'; md.renderer.rules.heading_close = () => '

\n'; md.renderer.rules.code_inline = (tokens, index) => `${escape(tokens[index]!.content)}`; @@ -128,7 +130,7 @@ export async function renderNote(markdown: string, options: RenderOptions): Prom tokens.splice(i + 1, end - i); } const html = md.renderer.render(tokens, md.options, {}); - // Changing the fingerprint also refreshes unchanged notes published with the old footer. + // Formatting changes also refresh already-synced notes whose Markdown is unchanged. const hash = await sha256(JSON.stringify([html, dependencies])); return { html, hash, warnings: [...warnings] }; } diff --git a/tests/core.test.ts b/tests/core.test.ts index 453f294..db710cc 100644 --- a/tests/core.test.ts +++ b/tests/core.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { DEFAULT_SETTINGS, type Binding, type Note, parseBinding, stripFrontmatter } from '../src/model'; +import { DEFAULT_SETTINGS, type Binding, type Note, parseBinding, sha256, stripFrontmatter } from '../src/model'; import type { Gateway, RemoteDocument } from '../src/basecamp'; import { relativeNotePath, selection } from '../src/selection'; import { renderNote } from '../src/render'; @@ -86,6 +86,22 @@ describe('selection and metadata', () => { }); describe('formatted content', () => { + it('keeps blank lines between long paragraphs and single breaks within a paragraph', async () => { + const paragraph = 'A long paragraph with words that wrap across many lines. '.repeat(80).trim(); + const result = await renderNote(`${paragraph}\n\nSecond **paragraph**.\nSame paragraph.\n\nLast paragraph.`, + { resolve: async () => ({}) }); + expect(result.html).toBe(`
${paragraph}
\n

\n` + + '
Second paragraph.
\nSame paragraph.
\n

\n' + + '
Last paragraph.
\n'); + }); + it('spaces paragraphs inside quotes and list items without adding gaps between list items', async () => { + const result = await renderNote('> First paragraph.\n>\n> Second paragraph.\n\n' + + '- First item.\n\n Another paragraph.\n- Second item.', { resolve: async () => ({}) }); + expect(result.html.match(/

<\/div>/g)).toHaveLength(2); + expect(result.html).toContain('
First paragraph.
\n

\n
Second paragraph.
'); + expect(result.html).toContain('
First item.
\n

\n
Another paragraph.
'); + expect(result.html).toContain('\n
  • \n
    Second item.
    \n
  • '); + }); it('uses only supported formatting and keeps table links', async () => { const rendered = await renderNote('## Heading\n\n**Bold** *italic* ~~strike~~ `code`\n\n- [x] Done\n\n```ts\n\n```\n\n| Name | Link |\n|---|---|\n| Alice | [Page](https://example.com) |', { resolve: async () => ({}) }); @@ -144,6 +160,24 @@ describe('formatted content', () => { }); describe('sync engine', () => { + it('repairs paragraph spacing on the next sync without a note edit or a duplicate document', async () => { + const { engine, api, note, host, documents } = setup(); + note.markdown = 'First paragraph.\n\nSecond paragraph.'; + const currentRender = host.render; + const oldHtml = '
    First paragraph.
    \n
    Second paragraph.
    \n'; + const oldHash = await sha256(JSON.stringify([oldHtml, []])); + host.render = async () => ({ html: oldHtml, hash: oldHash, warnings: [] }); + await engine.run([note.path], []); + + host.render = currentRender; + expect((await engine.run([note.path], [note]))[0]?.status).toBe('updated'); + expect(documents.get(10)?.content).toContain('
    \n

    \n
    Second paragraph.'); + expect(note.markdown).toBe('First paragraph.\n\nSecond paragraph.'); + expect(note.binding?.document).toBe(10); + expect((await engine.run([note.path], [note]))[0]?.status).toBe('unchanged'); + expect(api.createDocument).toHaveBeenCalledTimes(1); + expect(api.updateDocument).toHaveBeenCalledTimes(1); + }); it('removes the old footer from an unchanged note on its next sync, then returns to no-op updates', async () => { const { engine, api, note, documents } = await legacySetup(); expect((await engine.run([note.path], [note]))[0]?.status).toBe('updated'); diff --git a/versions.json b/versions.json index 7764a6f..38ed69d 100644 --- a/versions.json +++ b/versions.json @@ -1 +1 @@ -{"0.1.0":"1.11.4","0.1.1":"1.11.4","0.1.2":"1.11.4","0.1.3":"1.11.4","0.1.4":"1.11.4","0.1.5":"1.11.4","0.1.6":"1.11.4"} +{"0.1.0":"1.11.4","0.1.1":"1.11.4","0.1.2":"1.11.4","0.1.3":"1.11.4","0.1.4":"1.11.4","0.1.5":"1.11.4","0.1.6":"1.11.4","0.1.7":"1.11.4"}