import * as Y from 'yjs' import * as store from './store.js' import { newId, b64encode, docNameFor, projectIdOf, pageSlugOf } from './util.js' import { markdownToBlocks } from './md.js' import { hocuspocus, buildYBlock, createSuggestion, createPageProposal, deleteDoc } from './collab.js' const FIELD = 'default' // A personal test project seeded with every construct the suggestion/comment // system supports, so the owner can play with accepts/rejects/merges and reset // back to this exact state at any time. async function withDoc(docName, fn) { const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } }) try { let result await conn.transact(document => { result = fn(document) }) return result } finally { // see collab.js: an immediate unload races with clients connecting await conn.disconnect({ unloadImmediately: false }) } } const pageField = docName => (pageSlugOf(docName) === 'home' ? FIELD : `page:${pageSlugOf(docName)}`) const pageMap = (document, base, docName) => document.getMap(pageSlugOf(docName) === 'home' ? base : `${base}:${pageSlugOf(docName)}`) function svgMarkup(label, color) { return ` ${label} ` } function svgUpload(label, color) { return '/files/' + store.saveUpload(Buffer.from(svgMarkup(label, color)), 'image/svg+xml') } // self-contained data: URI so a sandboxed embed can show it with no network access function svgDataUri(label, color) { return 'data:image/svg+xml;base64,' + Buffer.from(svgMarkup(label, color)).toString('base64') } function seedPage(docName, markdown) { return withDoc(docName, document => { const fragment = document.getXmlFragment(pageField(docName)) if (fragment.length) fragment.delete(0, fragment.length) // the doc may still be loaded in memory from before the reset — the file // deletion alone doesn't clear its maps for (const mapName of ['suggestions', 'threads']) { const map = pageMap(document, mapName, docName) for (const key of [...map.keys()]) map.delete(key) } fragment.insert(0, markdownToBlocks(markdown).map(buildYBlock)) }) } // server-side comment thread on a character range of block `blockIdx` function seedThread(document, docName, { blockIdx, from, to, messages, resolved = false, suggestionId = null }) { const fragment = document.getXmlFragment(pageField(docName)) const block = fragment.get(blockIdx) const text = block?.get(0) if (!(text instanceof Y.XmlText)) return null const len = text.length const a = Math.min(from, len) const b = Math.min(to, len) const threadId = newId(10) const ythread = new Y.Map() pageMap(document, 'threads', docName).set(threadId, ythread) ythread.set('anchorStart', b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, a)))) ythread.set('anchorEnd', b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, b)))) ythread.set('excerpt', text.toString().slice(a, b)) ythread.set('resolved', resolved) if (suggestionId) ythread.set('suggestionId', suggestionId) const arr = new Y.Array() ythread.set('messages', arr) arr.push(messages.map(m => ({ id: newId(10), ts: Date.now(), ...m }))) return threadId } const HOME_MD = `# Playground This project is a safe sandbox: every kind of suggestion and comment lives here. Accept, reject, undo, merge — then hit **Reset** in the header to restore this exact state. ## Plain text The quick brown fox jumps over the lazy dog, and the document keeps calm. This paragraph has a small wording suggestion attached to it. This second paragraph carries two competing suggestions at once — both stay visible and either one can be accepted. Formatting survives suggestions: **bold**, *italic*, [links](https://huggingface.co) and \`inline code\` all round-trip. ## Lists - alpha - beta - gamma 1. first step 2. second step ## Checklist - [x] task lists render with checkboxes - [ ] this one has a suggestion attached ## Table | Feature | Status | | --- | --- | | Suggestions | done | | Tables | needs a suggestion | ## Code \`\`\`python def greet(name): message = "Hello, " + name return message \`\`\` > A blockquote to iterate on: this quote has a three-generation revision chain — use the arrows on its card. ## Live HTML embed Arbitrary HTML renders in a sandboxed frame — ideal for aligning figures side by side or dropping in an animated explainer. Hover it and hit **Edit HTML** to tweak the markup. It runs with no access to the page, your account, or the network. \`\`\`html-embed
Figure A — aligned
Figure B — aligned
\`\`\` ## Images Select the image below to try resizing and alignment. The paragraph after the images carries an image suggestion. That is all — happy testing!` const NOTES_MD = `# Notes A second page, to try the sidebar, page navigation, and cross-page agent work. - the structure page defines the tree - suggest changes to it like any text` export async function seedPlayground(username, existingId = null) { const id = existingId || newId(8) if (existingId) await deleteDoc(existingId) store.upsertRegistry(id, { title: 'Playground', createdBy: username, createdAt: Date.now(), updatedAt: Date.now(), playground: true, pages: {}, }) const img1 = svgUpload('sample figure', '#1d5c45') const img2 = svgUpload('suggested chart', '#7c4a9c') const home = HOME_MD.replace('Select the image below', `![sample figure](${img1})\n\nSelect the image above`) .replace('__EMBED_IMG1__', svgDataUri('figure A', '#1d5c45')) .replace('__EMBED_IMG2__', svgDataUri('figure B', '#7c4a9c')) await seedPage(id, home) await seedPage(docNameFor(id, 'notes'), NOTES_MD) store.upsertPageMeta(id, 'notes', { title: 'Notes', createdAt: Date.now() }) await seedPage(docNameFor(id, '_structure'), '```yaml\n- home\n- notes\n```') store.upsertPageMeta(id, '_structure', { title: 'Structure', createdAt: Date.now() }) // block indices in HOME_MD (after image insertion): find dynamically const blocks = markdownToBlocks(home) const idxOf = needle => blocks.findIndex(b => JSON.stringify(b).includes(needle)) const agent = { author: 'demo-agent', authorType: 'agent' } const mk = (blockIdx, replacement, rationale, extra = {}) => createSuggestion(id, { blockIndex: blockIdx, replacementMarkdown: replacement, rationale, ...agent, ...extra }) // 1. small inline rewrite await mk( idxOf('quick brown fox'), 'The quick brown fox leaps over the lazy dog, and the document stays calm. This paragraph has a small wording suggestion attached to it.', 'Tighter verbs: jumps → leaps, keeps → stays.' ) // 2. two competing suggestions on one paragraph const competing = idxOf('two competing suggestions') await mk(competing, 'This second paragraph carries two rival suggestions at once — both stay visible and either one can be accepted.', 'Option A: "rival".') await mk(competing, 'This second paragraph demonstrates overlapping suggestions — both stay visible and either one can be accepted.', 'Option B: "demonstrates overlapping".') // 3. list: add an item await mk(idxOf('gamma'), '- alpha\n- beta\n- gamma\n- delta', 'Adds the missing fourth item.') // task-list suggestion: check off the open item await mk( idxOf('this one has a suggestion attached'), '- [x] task lists render with checkboxes\n- [x] this one has a suggestion attached', 'Mark the second task as done.' ) // table suggestion: fill in the pending status await mk( idxOf('needs a suggestion'), '| Feature | Status |\n| --- | --- |\n| Suggestions | done |\n| Tables | done |', 'Update the table: tables are done.' ) // 4. new section insertion (formatted panel) await mk( idxOf('second step'), '1. first step\n2. second step\n\n## Checklist\n\n- [links](https://huggingface.co) render in panels\n- **bold** does too', 'Inserts a new section after the numbered list.' ) // 5. codeblock rewrite (word diff inside code) await mk( idxOf('greet'), '```python\ndef greet(name):\n message = f"Hello, {name}!"\n return message\n```', 'Use an f-string and add the exclamation mark.' ) // 6. three-generation revision chain on the blockquote const quoteIdx = idxOf('three-generation') const s1 = await mk(quoteIdx, '> A blockquote worth iterating on: generation one of the revision chain.', 'First attempt.') const s2 = await createSuggestion(id, { supersedes: s1.suggestion.id, replacementMarkdown: '> A blockquote worth iterating on: generation two, a bit sharper.', rationale: 'Second attempt — sharper.', ...agent, }) await createSuggestion(id, { supersedes: s2.suggestion.id, replacementMarkdown: '> A blockquote worth iterating on: generation three, the keeper. Use ‹ › on the card to compare.', rationale: 'Third attempt — the keeper.', ...agent, }) // 7. image suggestion await mk( idxOf('carries an image suggestion'), `The paragraph after the images carries an image suggestion.\n\n![suggested chart](${img2})`, 'Adds the missing chart below this paragraph.' ) // 8. threads: one open with an attached suggestion, one resolved const attachRes = await withDoc(id, document => { const openThread = seedThread(document, id, { blockIdx: idxOf('happy testing'), from: 0, to: 16, messages: [ { author: username, authorType: 'user', text: 'Can we end on something stronger? @demo-agent' }, { author: 'demo-agent', authorType: 'agent', text: 'Proposed a punchier closing line — see the related suggestion.' }, ], }) seedThread(document, id, { blockIdx: idxOf('Formatting survives'), from: 0, to: 20, resolved: true, messages: [ { author: username, authorType: 'user', text: 'Do links work in here?' }, { author: 'demo-agent', authorType: 'agent', text: 'They do — resolved.' }, ], }) return { openThread } }) await createSuggestion(id, { blockIndex: idxOf('happy testing'), replacementMarkdown: 'That is all — go break things, the Reset button has your back!', rationale: 'Punchier closing, as requested in the thread.', originThreadId: attachRes.openThread, ...agent, }) // 9. a proposed NEW page (shows as pending in the sidebar until accepted) await createPageProposal(id, { title: 'Proposed Page', contentMarkdown: '# Proposed Page\n\nAn agent proposed this whole page in one suggestion. Accept it from the sidebar to create it, or reject it.\n\n- [ ] try accepting me\n\n| Try | Result |\n| --- | --- |\n| Accept | page is created |', rationale: 'Demonstrates one-step new-page proposals.', ...agent, }) return { id } } export function findPlayground(username) { for (const [id, meta] of Object.entries(store.getRegistry())) { if (meta.playground && meta.createdBy === username) return id } return null }