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
\nSecond 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.
\nSecond 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
\nSecond 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"}