// Exporting a project as markdown. // // The zip is meant to be readable OUTSIDE cowrite: every image reference is // rewritten to a relative path and the bytes travel with it, so the markdown // renders in any editor rather than pointing back at an authenticated /files // URL that only works while you are signed in here. import path from 'node:path' import fs from 'node:fs' import { makeZip } from './zip.js' import { getProjectStructure } from './pages.js' import { docNameFor } from './util.js' import * as store from './store.js' // /files/<16 hex>. — the only shape store.saveUpload produces, and the // same pattern index.js validates before serving const FILE_RE = /\/files\/([a-f0-9]{16}\.(?:png|jpg|gif|webp|svg))/g // Depth-first, so the zip lists pages in the order the sidebar shows them. // Groups are visual-only: they contribute their children, not a file. function orderedSlugs(structure) { const out = [] const walk = nodes => { for (const node of nodes || []) { if (node.group == null && node.slug && node.slug !== '_structure') out.push(node.slug) walk(node.children) } } walk(structure?.tree) for (const slug of structure?.unfiled || []) if (slug !== '_structure') out.push(slug) return [...new Set(out)] } function safeName(slug) { return String(slug).replace(/[^A-Za-z0-9_.-]/g, '-') } // scope: 'project' (every page) | 'page' (just `slug`) export async function buildMarkdownExport({ hocuspocus, projectId, scope, slug, getSnapshot }) { const structure = getProjectStructure(hocuspocus, projectId) const meta = store.getRegistry()[projectId] const titles = structure?.titles || {} const slugs = scope === 'page' ? [slug] : orderedSlugs(structure) if (!slugs.length) slugs.push('home') const entries = [] const assets = new Set() for (const pageSlug of slugs) { const snapshot = await getSnapshot(docNameFor(projectId, pageSlug)) const markdown = snapshot?.markdown ?? '' for (const [, name] of markdown.matchAll(FILE_RE)) assets.add(name) // relative so the archive stands on its own once unzipped entries.push({ name: `${safeName(pageSlug)}.md`, data: markdown.replace(FILE_RE, 'assets/$1') }) } // the yaml IS the project's shape (order, nesting, group headers); without it // an unzipped project is a flat pile of files if (scope === 'project' && structure?.raw) { entries.push({ name: 'structure.yaml', data: structure.raw.endsWith('\n') ? structure.raw : structure.raw + '\n' }) } const missing = [] if (assets.size) { entries.push({ name: 'assets/', data: '' }) for (const name of [...assets].sort()) { try { entries.push({ name: `assets/${name}`, data: fs.readFileSync(path.join(store.UPLOADS_DIR, name)) }) } catch { missing.push(name) // an image whose bytes are gone must not fail the whole export } } } if (missing.length) { entries.push({ name: 'assets/MISSING.txt', data: `These images are referenced by the markdown but their files were not found on the server:\n\n${missing.map(m => `- ${m}`).join('\n')}\n`, }) } const label = scope === 'page' ? titles[slug] || slug : meta?.title || projectId const stem = safeName(String(label).trim().toLowerCase().replace(/\s+/g, '-')) || 'export' return { filename: `${stem}${scope === 'page' ? '' : '-project'}.zip`, zip: makeZip(entries), pages: slugs, assets: assets.size, missing } }