feat: expand mobile editor serialization
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
99
apps/mobile/src/mobileEditorHtmlMarkdown.ts
Normal file
99
apps/mobile/src/mobileEditorHtmlMarkdown.ts
Normal 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(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user