feat: expand mobile editor serialization

This commit is contained in:
lucaronin
2026-05-04 11:01:33 +02:00
parent f7eea2d997
commit bd8e498505
4 changed files with 158 additions and 41 deletions

View File

@@ -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: [
'<h2>Section</h2>',
'<p>Use <strong>bold</strong>, <em>emphasis</em>, <code>code</code>, and <a href="https://tolaria.app">links</a>.</p>',
'<ol><li>First</li><li>Second</li></ol>',
].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: [
'<ul data-type="taskList">',
'<li data-checked="true"><label><input type="checkbox" checked=""></label><div><p>Done</p></div></li>',
'<li data-checked="false"><label><input type="checkbox"></label><div><p>Todo</p></div></li>',
'</ul>',
].join(''),
})
expect(draft).toMatchObject({
persistable: true,
canonicalMarkdown: '- [x] Done\n- [ ] Todo',
})
})
it('blocks unsupported HTML instead of persisting unknown editor output', () => {
expect(
createMobileEditorDraft({

View File

@@ -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(/^<h1/i)) {
return `# ${textContent(block)}`
}
if (block.match(/^<ul/i)) {
return listItemText(block).map((text) => `- ${text}`).join('\n')
}
return textContent(block)
}
function listItemText(block: string) {
return [...block.matchAll(/<li(?:\s[^>]*)?>([\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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}

View File

@@ -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+</g, '><')
}
function headingMarkdownLevel(input: HtmlInput) {
const match = input.html.match(/^<h([1-6])/i)
return match ? Number(match[1]) : null
}
function isListBlock(input: HtmlInput) {
return input.html.match(/^<(ul|ol)/i)
}
function listItemMarkdown(input: HtmlInput) {
const ordered = input.html.match(/^<ol/i)
return [...input.html.matchAll(/<li(?:\s[^>]*)?>([\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(/<input[^>]+checked/i)) {
return '[x]'
}
if (input.html.match(/data-checked=["']false/i) || input.html.match(/<input[^>]+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(/<br\s*\/?>/gi, '\n')
.replace(/<(strong|b)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '**$2**')
.replace(/<(em|i)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/gi, '*$2*')
.replace(/<code(?:\s[^>]*)?>([\s\S]*?)<\/code>/gi, '`$1`')
.replace(/<a(?:\s[^>]*)?href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, '[$2]($1)')
}
function stripRemainingTags(value: string) {
return value.replace(/<[^>]+>/g, '')
}
function decodeHtmlEntities(input: { text: string }) {
return input.text
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&')
}

View File

@@ -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