File size: 3,451 Bytes
3b2fb14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
// 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 }
}