From bd8e4985052a0bb48bbfb2efecc77b4ef1795cea Mon Sep 17 00:00:00 2001 From: lucaronin Date: Mon, 4 May 2026 11:01:33 +0200 Subject: [PATCH] feat: expand mobile editor serialization --- apps/mobile/src/mobileEditorDraft.test.ts | 48 ++++++++++ apps/mobile/src/mobileEditorDraft.ts | 41 +-------- apps/mobile/src/mobileEditorHtmlMarkdown.ts | 99 +++++++++++++++++++++ docs/MOBILE_PROGRESS.md | 11 ++- 4 files changed, 158 insertions(+), 41 deletions(-) create mode 100644 apps/mobile/src/mobileEditorHtmlMarkdown.ts diff --git a/apps/mobile/src/mobileEditorDraft.test.ts b/apps/mobile/src/mobileEditorDraft.test.ts index a1d2cb2b..3f1c3b5a 100644 --- a/apps/mobile/src/mobileEditorDraft.test.ts +++ b/apps/mobile/src/mobileEditorDraft.test.ts @@ -53,6 +53,54 @@ describe('mobile editor draft', () => { }) }) + it('serializes headings, ordered lists, and inline marks', () => { + const draft = createMobileEditorDraft({ + note: { + id: 'formatting', + title: 'Formatting', + content: '# Formatting', + }, + editorHtml: [ + '

Section

', + '

Use bold, emphasis, code, and links.

', + '
  1. First
  2. Second
', + ].join(''), + }) + + expect(draft).toMatchObject({ + persistable: true, + canonicalMarkdown: [ + '## Section', + '', + 'Use **bold**, *emphasis*, `code`, and [links](https://tolaria.app).', + '', + '1. First', + '1. Second', + ].join('\n'), + }) + }) + + it('serializes TenTap-style task list items', () => { + const draft = createMobileEditorDraft({ + note: { + id: 'tasks', + title: 'Tasks', + content: '# Tasks', + }, + editorHtml: [ + '', + ].join(''), + }) + + expect(draft).toMatchObject({ + persistable: true, + canonicalMarkdown: '- [x] Done\n- [ ] Todo', + }) + }) + it('blocks unsupported HTML instead of persisting unknown editor output', () => { expect( createMobileEditorDraft({ diff --git a/apps/mobile/src/mobileEditorDraft.ts b/apps/mobile/src/mobileEditorDraft.ts index 05546b85..8e975a31 100644 --- a/apps/mobile/src/mobileEditorDraft.ts +++ b/apps/mobile/src/mobileEditorDraft.ts @@ -1,5 +1,6 @@ import { splitFrontmatter } from '@tolaria/markdown' import type { MobileEditorDocumentInput } from './mobileEditorDocument' +import { serializeSupportedMobileEditorHtml } from './mobileEditorHtmlMarkdown' export type MobileEditorDraft = | { @@ -24,7 +25,7 @@ export function createMobileEditorDraft({ editorHtml: string note: MobileEditorDocumentInput }): MobileEditorDraft { - const markdownBody = serializeSupportedHtml(editorHtml) + const markdownBody = serializeSupportedMobileEditorHtml({ editorHtml }) if (!markdownBody) { return createBlockedDraft({ editorHtml, note }) } @@ -64,41 +65,3 @@ function withFrontmatter({ const [frontmatter] = splitFrontmatter(sourceMarkdown) return frontmatter ? `${frontmatter}${markdownBody}` : markdownBody } - -function serializeSupportedHtml(editorHtml: string) { - const blocks = editorHtml.match(/<(h1|p|ul)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi) - if (!blocks || blocks.join('') !== editorHtml.trim()) { - return null - } - - return blocks?.map(serializeBlock).join('\n\n') ?? null -} - -function serializeBlock(block: string) { - if (block.match(/^

`- ${text}`).join('\n') - } - - return textContent(block) -} - -function listItemText(block: string) { - return [...block.matchAll(/]*)?>([\s\S]*?)<\/li>/gi)].map((match) => textContent(match[1])) -} - -function textContent(value: string) { - return decodeHtmlEntities(value.replace(/<[^>]+>/g, '').trim()) -} - -function decodeHtmlEntities(value: string) { - return value - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, '&') -} diff --git a/apps/mobile/src/mobileEditorHtmlMarkdown.ts b/apps/mobile/src/mobileEditorHtmlMarkdown.ts new file mode 100644 index 00000000..bad8016a --- /dev/null +++ b/apps/mobile/src/mobileEditorHtmlMarkdown.ts @@ -0,0 +1,99 @@ +type EditorHtmlInput = { + editorHtml: string +} + +type HtmlInput = { + html: string +} + +type ListItemInput = HtmlInput & { + ordered: boolean +} + +export function serializeSupportedMobileEditorHtml(input: EditorHtmlInput) { + const html = normalizeBlockSpacing(input) + const blocks = html.match(/<(h[1-6]|p|ul|ol)(?:\s[^>]*)?>[\s\S]*?<\/\1>/gi) + if (!blocks || blocks.join('') !== html) { + return null + } + + return blocks.map((block) => serializeBlock({ html: block })).join('\n\n') +} + +function serializeBlock(input: HtmlInput) { + const headingLevel = headingMarkdownLevel(input) + if (headingLevel) { + return `${'#'.repeat(headingLevel)} ${inlineMarkdown(input)}` + } + + if (isListBlock(input)) { + return listItemMarkdown(input).join('\n') + } + + return inlineMarkdown(input) +} + +function normalizeBlockSpacing(input: EditorHtmlInput) { + return input.editorHtml.trim().replace(/>\s+<') +} + +function headingMarkdownLevel(input: HtmlInput) { + const match = input.html.match(/^]*)?>([\s\S]*?)<\/li>/gi)].map((match) => + formatListItem({ ordered: Boolean(ordered), html: match[0] }), + ) +} + +function formatListItem(input: ListItemInput) { + const taskMarker = markdownTaskMarker(input) + const prefix = taskMarker ? `- ${taskMarker}` : input.ordered ? '1.' : '-' + + return `${prefix} ${inlineMarkdown(input)}` +} + +function markdownTaskMarker(input: HtmlInput) { + if (input.html.match(/data-checked=["']true/i) || input.html.match(/]+checked/i)) { + return '[x]' + } + + if (input.html.match(/data-checked=["']false/i) || input.html.match(/]+type=["']checkbox/i)) { + return '[ ]' + } + + return null +} + +function inlineMarkdown(input: HtmlInput) { + return decodeHtmlEntities({ text: stripRemainingTags(markInlineHtml(input)).trim() }) +} + +function markInlineHtml(input: HtmlInput) { + return input.html + .replace(//gi, '\n') + .replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**') + .replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*') + .replace(/]*)?>([\s\S]*?)<\/code>/gi, '`$1`') + .replace(/]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)') +} + +function stripRemainingTags(value: string) { + return value.replace(/<[^>]+>/g, '') +} + +function decodeHtmlEntities(input: { text: string }) { + return input.text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} diff --git a/docs/MOBILE_PROGRESS.md b/docs/MOBILE_PROGRESS.md index 3ad12d03..e7bd52c9 100644 --- a/docs/MOBILE_PROGRESS.md +++ b/docs/MOBILE_PROGRESS.md @@ -8,7 +8,7 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Branch: `codex/mobile` - Active phase: Phase 2 - Mobile Shell -- Active slice: Seed app-local mobile demo vault +- Active slice: Expand TenTap Markdown serialization - Push policy: commit locally; do not push unless explicitly requested - Validation target: iPad/iOS simulator first @@ -55,6 +55,8 @@ This file is the resumable working log for Tolaria mobile. The strategy and road - Created [ADR-0111](./adr/0111-expo-file-system-vault-storage.md) to record the mobile filesystem dependency and app-local vault storage path. - Added a mobile vault seeding boundary that writes starter Markdown files only when the app-local vault is empty. - Wired the mobile shell to load its starter notes through the native Expo FileSystem storage driver and stored repository path, with fixture notes retained as the fallback while storage initializes. +- Expanded supported TenTap HTML serialization to include H1-H6 headings, ordered lists, task-list markers, inline strong/emphasis/code, and links while continuing to block unsupported block HTML. +- Split the mobile editor HTML serializer into its own CodeScene-10 module so the draft boundary stays small. ## Next Action @@ -62,7 +64,7 @@ Continue Phase 2 with the next mobile shell slice: 1. Dismiss or suppress Expo Go's first-run tools modal during simulator QA so screenshots capture the app without the overlay. 2. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists. -3. Expand the serializer for additional TenTap output needed by real notes: links, emphasis, code, headings, ordered lists, and task lists. +3. Add a save command boundary that writes persistable TenTap Markdown drafts back to mobile vault storage. ## Verification Log @@ -169,6 +171,11 @@ Continue Phase 2 with the next mobile shell slice: - CodeScene after app-local demo vault seeding: `apps/mobile/src/MobileApp.tsx`, `apps/mobile/src/mobileDemoVault.ts`, `apps/mobile/src/mobileExpoVaultStorage.ts`, `apps/mobile/src/mobileVaultSeed.ts`, and `apps/mobile/src/mobileVaultSeed.test.ts` scored `10`; `apps/mobile/src/demoData.ts` returned no scorable code and no findings. - `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after app-local demo vault seeding. - `pnpm --filter @tolaria/mobile exec expo start --ios --clear --port 8087` launched on `iPad Pro 13-inch (M4)` after app-local demo vault seeding; screenshot captured at `/tmp/tolaria-mobile-seeded-ipad.png`. The app rendered via the stored repository path behind Expo Go's first-run Tools modal with no red runtime error overlay. +- `pnpm --filter @tolaria/mobile test -- src/mobileEditorDraft.test.ts` passed after expanded TenTap serialization: 12 files / 42 tests. +- `pnpm --filter @tolaria/mobile test` passed after expanded TenTap serialization: 12 files / 42 tests. +- `pnpm --filter @tolaria/mobile typecheck` passed after expanded TenTap serialization. +- CodeScene after expanded TenTap serialization: `apps/mobile/src/mobileEditorDraft.ts`, `apps/mobile/src/mobileEditorDraft.test.ts`, and `apps/mobile/src/mobileEditorHtmlMarkdown.ts` scored `10`. +- `pnpm --filter @tolaria/mobile exec expo export --platform ios --output-dir /tmp/tolaria-mobile-export` passed after expanded TenTap serialization. ## Risks / Watch Items