/**
* SitePublisher — Mirror web app + public notebooks to a single Hyperdrive
*
* Creates a "site drive" that can be accessed via hyper:// in Agregore browser.
* The drive contains:
* /index.html, /web/css/*, /web/js/* — SPA files
* /api/notebooks.json — notebook listing
* /api/notebooks/{slug}/metadata.json — notebook metadata
* /api/notebooks/{slug}/notes.json — note listing
* /api/notebooks/{slug}/notes/{id}.json — individual notes
* /api/recent.json — recent notes feed
* /api/media/{slug}/* — media files
*/
import fs from 'bare-fs'
import path from 'bare-path'
import Hyperdrive from 'hyperdrive'
export class SitePublisher {
constructor (store, noteManager, driveManager) {
this.store = store
this.noteManager = noteManager
this.driveManager = driveManager
this.drive = null
this._ready = false
}
/**
* Initialize the site drive.
*/
async ready () {
if (this._ready) return
this.drive = new Hyperdrive(this.store.namespace('site-public'))
await this.drive.ready()
this._ready = true
}
/**
* Get the site drive key (hex).
*/
get key () {
return this.drive ? this.drive.key.toString('hex') : null
}
/**
* Get the discovery key for Hyperswarm announcement.
*/
get discoveryKey () {
return this.drive ? this.drive.discoveryKey : null
}
/**
* Full publish — mirror web files + all public notebook data.
*/
async publish () {
await this.ready()
await this._publishWebFiles()
await this._publishNotebooks()
console.log('[SitePublisher] Published site to hyper://' + this.key + '/')
}
/**
* Publish updated data for a specific notebook (call after note save/delete).
*/
async publishNotebook (slug) {
await this.ready()
const notebook = await this.noteManager.getNotebook(slug)
if (!notebook) return
// Skip private/encrypted
if (notebook.visibility === 'private' || notebook.encrypted) {
// Remove if previously published
await this._removeNotebookData(slug)
await this._publishNotebookListing()
await this._publishRecentNotes()
return
}
await this._publishNotebookData(slug, notebook)
await this._publishNotebookListing()
await this._publishRecentNotes()
await this._publishNotebookMedia(slug)
}
// ==================== INTERNAL ====================
/**
* Mirror the web app files (index.html, css, js) into the site drive.
*/
async _publishWebFiles () {
const webRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..', 'web')
// index.html
const indexHtml = fs.readFileSync(path.join(webRoot, 'index.html'), 'utf-8')
await this.drive.put('/index.html', Buffer.from(indexHtml))
// CSS files
const cssDir = path.join(webRoot, 'css')
try {
const cssFiles = fs.readdirSync(cssDir)
for (const file of cssFiles) {
const content = fs.readFileSync(path.join(cssDir, file))
await this.drive.put('/web/css/' + file, content)
}
} catch {}
// JS files
const jsDir = path.join(webRoot, 'js')
try {
const jsFiles = fs.readdirSync(jsDir)
for (const file of jsFiles) {
const content = fs.readFileSync(path.join(jsDir, file))
await this.drive.put('/web/js/' + file, content)
}
} catch {}
}
/**
* Publish all public notebook data as static JSON.
*/
async _publishNotebooks () {
const notebooks = await this.noteManager.listNotebooks()
for (const nb of notebooks) {
if (nb.visibility === 'private' || nb.encrypted) continue
await this._publishNotebookData(nb.slug, nb)
await this._publishNotebookMedia(nb.slug)
}
await this._publishNotebookListing()
await this._publishRecentNotes()
}
/**
* Publish a single notebook's data (metadata + notes).
*/
async _publishNotebookData (slug, notebook) {
// Metadata
const meta = {
slug: notebook.slug,
name: notebook.name,
description: notebook.description,
noteCount: notebook.noteCount,
visibility: notebook.visibility,
createdAt: notebook.createdAt,
updatedAt: notebook.updatedAt
}
await this.drive.put(
'/api/notebooks/' + slug + '/metadata.json',
Buffer.from(JSON.stringify(meta, null, 2))
)
// Notes
const notes = await this.noteManager.listNotes(slug)
const noteSummaries = notes.map(n => ({
id: n.id,
title: n.title,
tags: n.tags || [],
createdAt: n.createdAt,
updatedAt: n.updatedAt,
snippet: (n.content || '').substring(0, 200)
}))
await this.drive.put(
'/api/notebooks/' + slug + '/notes.json',
Buffer.from(JSON.stringify({ notes: noteSummaries }, null, 2))
)
// Individual notes
for (const note of notes) {
await this.drive.put(
'/api/notebooks/' + slug + '/notes/' + note.id + '.json',
Buffer.from(JSON.stringify(note, null, 2))
)
}
}
/**
* Copy media files from notebook drive to site drive.
*/
async _publishNotebookMedia (slug) {
const nbDrive = await this.driveManager.getDrive(slug)
if (!nbDrive) return
try {
const mediaFiles = await nbDrive.readdir('/media')
for await (const file of mediaFiles) {
const buf = await nbDrive.get('/media/' + file)
if (buf) {
await this.drive.put('/api/media/' + slug + '/' + file, buf)
}
}
} catch {
// No media directory
}
}
/**
* Publish the notebook listing JSON.
*/
async _publishNotebookListing () {
const notebooks = await this.noteManager.listNotebooks()
const publicNbs = notebooks
.filter(nb => nb.visibility !== 'private' && !nb.encrypted)
.map(nb => ({
slug: nb.slug,
name: nb.name,
description: nb.description,
noteCount: nb.noteCount,
visibility: nb.visibility,
createdAt: nb.createdAt,
updatedAt: nb.updatedAt
}))
await this.drive.put(
'/api/notebooks.json',
Buffer.from(JSON.stringify({ notebooks: publicNbs }, null, 2))
)
}
/**
* Publish the recent notes feed JSON.
*/
async _publishRecentNotes () {
const recent = await this.noteManager.recentNotes({ limit: 50 })
await this.drive.put(
'/api/recent.json',
Buffer.from(JSON.stringify({ notes: recent }, null, 2))
)
}
/**
* Remove a notebook's data from the site drive.
*/
async _removeNotebookData (slug) {
try {
await this.drive.del('/api/notebooks/' + slug + '/metadata.json')
await this.drive.del('/api/notebooks/' + slug + '/notes.json')
// Remove individual note files
const noteFiles = await this.drive.readdir('/api/notebooks/' + slug + '/notes')
for await (const file of noteFiles) {
await this.drive.del('/api/notebooks/' + slug + '/notes/' + file)
}
} catch {
// May not exist
}
try {
const mediaFiles = await this.drive.readdir('/api/media/' + slug)
for await (const file of mediaFiles) {
await this.drive.del('/api/media/' + slug + '/' + file)
}
} catch {}
}
}
export default SitePublisher