Spaces:
Sleeping
Sleeping
| // 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 } | |
| } | |