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.
',
+ '- First
- 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