cowrite-dev / server /export.js
lvwerra's picture
lvwerra HF Staff
Download: this page or the project, as markdown or PDF
3b2fb14
Raw
History Blame Contribute Delete
3.45 kB
// 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>.<ext> — 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 }
}