diff --git a/src/ro-crate-html-output/index.js b/src/ro-crate-html-output/index.js index 8e4889b..a3570b9 100644 --- a/src/ro-crate-html-output/index.js +++ b/src/ro-crate-html-output/index.js @@ -14,18 +14,23 @@ import { resolveProfileGroups } from "./layout.js"; let crateToPreviewHtml, crateToMultiPageHtml; -let writeFile, writeFileAtPath, readJsonFromFolder, readFileTextFromDirectory, verifyPermission, fileExists; +let writeFile, writeFileAtPath, readJsonFromFolder, readFileTextFromDirectory, verifyPermission, fileExists, getFileHandleAtPath; let bustCacheUrl, buildGitHubTreeUrl, fetchGitHubTextFile, listGitHubFolder; export function createPlugin(deps) { ({ crateToPreviewHtml, crateToMultiPageHtml } = deps); - ({ writeFile, writeFileAtPath, readJsonFromFolder, readFileTextFromDirectory, verifyPermission, fileExists } = deps); + ({ writeFile, writeFileAtPath, readJsonFromFolder, readFileTextFromDirectory, verifyPermission, fileExists, getFileHandleAtPath } = deps); ({ bustCacheUrl, buildGitHubTreeUrl, fetchGitHubTextFile, listGitHubFolder } = deps); return plugin; } const HTML_FILE = "ro-crate-preview.html"; const MULTIPAGE_DIR = "ro-crate-preview_html"; +// Where "Publish subset only" copies the files that survived +// filterCrateToPublished, so a deployment of the generated preview alone +// (this folder + ro-crate-preview.html/ro-crate-preview_html/) never exposes +// a file that was filtered out — see copyPublishedFilesToPreviewFolder. +const PREVIEW_FILES_DIR = "ro-crate-preview-files"; const TEMPLATE_REPO_OWNER = "Language-Research-Technology"; const TEMPLATE_REPO_NAME = "rocss-templates"; const TEMPLATE_REPO_REF = "main"; @@ -195,6 +200,93 @@ export function filterCrateToPublished(crate, log) { log(`Publish subset only: kept ${keep.size} of ${visited.size} collection/object/file entit${visited.size === 1 ? "y" : "ies"}.`, "muted"); } +// Every File entity still in `crate` (call after filterCrateToPublished, so +// this is exactly the set that survived the publish filter) whose @id is a +// folder-relative path rather than an external URL — mirrors the same +// absolute-URL guard reconcileFileEntities (src/crate.js) uses to tell a +// real on-disk file apart from a File entity that only records a remote +// reference and has nothing to copy. +export function collectFilePaths(crate) { + const paths = []; + const seen = new Set(); + for (const entity of crate.graph) { + const id = entity?.["@id"]; + if (!id || seen.has(id)) continue; + const types = Array.isArray(entity["@type"]) ? entity["@type"] : [entity["@type"]]; + if (!types.includes("File")) continue; + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(id)) continue; + seen.add(id); + paths.push(id); + } + return paths; +} + +// Copies every published file (collectFilePaths) from the picked crate +// folder into PREVIEW_FILES_DIR, so that folder plus the generated HTML is a +// self-contained, safe-to-publish subset — the crate folder itself still +// holds every file regardless of custom:publish, published or not. Returns +// a Map of original crate-relative path -> its copy's path, for +// rewriteToPreviewFilesFolder to redirect the generated HTML's links to. +export async function copyPublishedFilesToPreviewFolder(crate, dirHandle, log) { + const filePaths = collectFilePaths(crate); + const assetMap = new Map(); + if (!filePaths.length) return assetMap; + + try { + await dirHandle.removeEntry(PREVIEW_FILES_DIR, { recursive: true }); + } catch { + // no pre-existing ro-crate-preview-files/ to remove — fine. + } + + let copied = 0; + let missing = 0; + for (const relPath of filePaths) { + const fileHandle = await getFileHandleAtPath(dirHandle, relPath); + if (!fileHandle) { + log(`Publish subset only: file referenced by the crate is missing from the folder — not copied into ${PREVIEW_FILES_DIR}/: ${relPath}`, "warn"); + missing++; + continue; + } + const file = await fileHandle.getFile(); + await writeFileAtPath(dirHandle, `${PREVIEW_FILES_DIR}/${relPath}`, file); + assetMap.set(relPath, `${PREVIEW_FILES_DIR}/${relPath}`); + copied++; + } + log( + `Publish subset only: copied ${copied} file(s) into ${PREVIEW_FILES_DIR}/${missing ? ` (${missing} missing from the folder)` : ""}.`, + copied ? "ok" : "warn" + ); + return assetMap; +} + +function escapeRegExp(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +// Redirects every href="…"/src="…" and CSS url(…) reference to one of +// assetMap's original paths over to its copy under ro-crate-preview-files/, +// across the whole rendered page. Works at the regex level, like +// fixEncodedSlashes (src/crate.js) above the html this runs on — a +// DOM/cheerio parse-and-reserialize risks subtly reformatting markup that +// isn't ours to rewrite, on output real users will deploy as-is. Matching +// each path as one literal (rather than splitting at '#'/'?' first) means a +// file whose own name contains '#' or '?' still matches correctly instead +// of being mistaken for a fragment/query — see preview_assets.js's +// mapAssetUrl in chaos2crate for the in-app-preview equivalent of this same +// hazard. +export function rewriteToPreviewFilesFolder(html, assetMap) { + if (!html || !assetMap || !assetMap.size) return html; + const alternation = [...assetMap.keys()] + .sort((a, b) => b.length - a.length) + .map(escapeRegExp) + .join("|"); + const attrPattern = new RegExp(`(src|href)="(${alternation})([?#][^"]*)?"`, "g"); + const urlFnPattern = new RegExp(`url\\(\\s*(['"]?)(${alternation})([?#][^'")]*)?\\1\\s*\\)`, "g"); + return html + .replace(attrPattern, (whole, attr, path, suffix) => `${attr}="${assetMap.get(path)}${suffix || ""}"`) + .replace(urlFnPattern, (whole, quote, path, suffix) => `url(${quote}${assetMap.get(path)}${suffix || ""}${quote})`); +} + function formatDurationMs(ms) { if (!Number.isFinite(ms) || ms < 0) return "0.00s"; if (ms < 1000) return `${ms}ms`; @@ -530,6 +622,7 @@ const plugin = { outputPaths: [ { path: HTML_FILE, kind: "file" }, { path: MULTIPAGE_DIR, kind: "dir" }, + { path: PREVIEW_FILES_DIR, kind: "dir" }, ], optionSchema: { key: "makeHtml", label: "Generate ro-crate-preview.html", default: true, @@ -545,7 +638,7 @@ const plugin = { placeholder: "https://example.org/my-site", hint: "Optional. The hostname this site will be published under, used to build absolute preview-card (Open Graph) image and link URLs. Leave blank to skip those tags." }, { key: "publishOnly", label: "Publish subset only", default: false, - hint: "Off = every collection/object/file appears. On = an entity only appears if it has custom:publish:true set, or sits inside a collection/object that has custom:publish:true (an entity's own custom:publish:false always wins over an inherited true) — typically a File-level column in the source spreadsheet. Affects only this generated HTML, not ro-crate-metadata.json/.xlsx." }, + hint: "Off = every collection/object/file appears. On = an entity only appears if it has custom:publish:true set, or sits inside a collection/object that has custom:publish:true (an entity's own custom:publish:false always wins over an inherited true) — typically a File-level column in the source spreadsheet. The files that remain are also copied into ro-crate-preview-files/, with this generated HTML's own links updated to point there, so publishing just the generated preview plus that folder never exposes a file that was filtered out. Affects only this generated HTML, not ro-crate-metadata.json/.xlsx." }, { key: "styledPreview", label: "Upload template files", default: false, hint: "Off = the library's plain preview.", children: [ { key: "configFile", type: "file", label: "Config (JSON)", accept: ".json,.css,.html,application/json,text/css,text/html", @@ -565,8 +658,12 @@ const plugin = { log(`${HTML_FILE} exists and overwrite is off — skipped.`, "warn"); return; } + let publishFileAssetMap = null; try { - if (options.publishOnly) filterCrateToPublished(crate, log); + if (options.publishOnly) { + filterCrateToPublished(crate, log); + publishFileAssetMap = await copyPublishedFilesToPreviewFolder(crate, dirHandle, log); + } applyCollectionLabelOverrides(crate, options); // resolveTerm() (used below to place profile-declared property // names) needs the context resolved first — crateToPreviewHtml/ @@ -691,7 +788,10 @@ const plugin = { const progressStep = totalPages >= 50 ? 25 : totalPages >= 10 ? 10 : 0; for (let i = 0; i < multi.pages.length; i += 1) { const page = multi.pages[i]; - await writeFileAtPath(dirHandle, page.path, page.html); + const pageHtml = publishFileAssetMap && publishFileAssetMap.size + ? rewriteToPreviewFilesFolder(page.html, publishFileAssetMap) + : page.html; + await writeFileAtPath(dirHandle, page.path, pageHtml); const written = i + 1; if (progressStep && (written % progressStep === 0 || written === totalPages)) { log(`Preview: wrote ${written}/${totalPages} page file(s)…`, "muted"); @@ -721,6 +821,9 @@ const plugin = { renderMs = Date.now() - plainRenderStartMs; ctx.lastHtmlTemplate = null; } + if (publishFileAssetMap && publishFileAssetMap.size) { + html = rewriteToPreviewFilesFolder(html, publishFileAssetMap); + } await writeFile(dirHandle, HTML_FILE, html); log(`Wrote ${HTML_FILE}.`, "ok"); const totalPreviewMs = Date.now() - previewStartMs;