| 1 | import crypto from 'crypto' |
| 2 | import fs from 'fs' |
| 3 | import path from 'path' |
| 4 | import { |
| 5 | MASTER_CSS_FILENAME, |
| 6 | MASTER_DIRECTORY, |
| 7 | MASTER_HTML_FILENAME, |
| 8 | buildDefaultMasterConfig, |
| 9 | buildMasterCss, |
| 10 | buildMasterElementsHtml, |
| 11 | normalizeMasterConfig, |
| 12 | parseMasterCss, |
| 13 | parseMasterElementsHtml, |
| 14 | type SessionMasterConfig |
| 15 | } from '@shared/master' |
| 16 | import { |
| 17 | MASTER_LAYOUTS_FILENAME, |
| 18 | buildDefaultSessionLayoutLibrary, |
| 19 | normalizeSessionLayoutLibrary, |
| 20 | type SessionLayoutLibrary |
| 21 | } from '@shared/layout-master' |
| 22 | |
| 23 | export type SessionMasterReadResult = { |
| 24 | css: string |
| 25 | html: string |
| 26 | config: SessionMasterConfig |
| 27 | exists: boolean |
| 28 | } |
| 29 | |
| 30 | export type SessionLayoutLibraryReadResult = { |
| 31 | library: SessionLayoutLibrary |
| 32 | exists: boolean |
| 33 | } |
| 34 | |
| 35 | const rebaseMasterAssetUrls = (css: string): string => |
| 36 | css |
| 37 | .replace(/url\(\s*(["'])\.\/assets\//gi, 'url($1../assets/') |
| 38 | .replace(/url\(\s*(["'])\.\/images\//gi, 'url($1../images/') |
| 39 | |
| 40 | const unbaseMasterAssetUrls = (css: string): string => |
| 41 | css |
| 42 | .replace(/url\(\s*(["'])\.\.\/assets\//gi, 'url($1./assets/') |
| 43 | .replace(/url\(\s*(["'])\.\.\/images\//gi, 'url($1./images/') |
| 44 | |
| 45 | const writeAtomically = async (filePath: string, content: string): Promise<void> => { |
| 46 | const tempPath = `${filePath}.${crypto.randomUUID()}.tmp` |
| 47 | await fs.promises.mkdir(path.dirname(filePath), { recursive: true }) |
| 48 | try { |
| 49 | await fs.promises.writeFile(tempPath, content, 'utf-8') |
| 50 | await fs.promises.rename(tempPath, filePath) |
| 51 | } finally { |
| 52 | await fs.promises.rm(tempPath, { force: true }).catch(() => undefined) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | const readFileIfExists = async (filePath: string): Promise<string | null> => { |
| 57 | try { |
| 58 | return await fs.promises.readFile(filePath, 'utf-8') |
| 59 | } catch (error) { |
| 60 | if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null |
| 61 | throw error |
| 62 | } |
| 63 | } |
| 64 | |
| 65 | export const getSessionMasterDirectory = (projectDir: string): string => |
| 66 | path.join(path.resolve(projectDir), MASTER_DIRECTORY) |
| 67 | |
| 68 | export const getSessionMasterPath = (projectDir: string): string => |
| 69 | path.join(getSessionMasterDirectory(projectDir), MASTER_CSS_FILENAME) |
| 70 | |
| 71 | export const getSessionMasterHtmlPath = (projectDir: string): string => |
| 72 | path.join(getSessionMasterDirectory(projectDir), MASTER_HTML_FILENAME) |
| 73 | |
| 74 | export const getSessionMasterLayoutsPath = (projectDir: string): string => |
| 75 | path.join(getSessionMasterDirectory(projectDir), MASTER_LAYOUTS_FILENAME) |
| 76 | |
| 77 | const toResult = (css: string, html: string, exists: boolean): SessionMasterReadResult => { |
| 78 | const cssConfig = parseMasterCss(unbaseMasterAssetUrls(css)) |
| 79 | return { |
| 80 | css, |
| 81 | html, |
| 82 | config: normalizeMasterConfig({ |
| 83 | ...cssConfig, |
| 84 | elements: parseMasterElementsHtml(html) |
| 85 | }), |
| 86 | exists |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | export async function readSessionMaster(projectDir: string): Promise<SessionMasterReadResult> { |
| 91 | const [canonicalCss, masterHtml] = await Promise.all([ |
| 92 | readFileIfExists(getSessionMasterPath(projectDir)), |
| 93 | readFileIfExists(getSessionMasterHtmlPath(projectDir)) |
| 94 | ]) |
| 95 | if (canonicalCss === null) { |
| 96 | const config = buildDefaultMasterConfig() |
| 97 | return { |
| 98 | css: buildMasterCss(config), |
| 99 | html: masterHtml || buildMasterElementsHtml(config.elements), |
| 100 | config: normalizeMasterConfig({ ...config, elements: parseMasterElementsHtml(masterHtml || '') }), |
| 101 | exists: false |
| 102 | } |
| 103 | } |
| 104 | const cssConfig = parseMasterCss(unbaseMasterAssetUrls(canonicalCss)) |
| 105 | return toResult(canonicalCss, masterHtml || buildMasterElementsHtml(cssConfig.elements), true) |
| 106 | } |
| 107 | |
| 108 | export async function readSessionLayoutLibrary( |
| 109 | projectDir: string |
| 110 | ): Promise<SessionLayoutLibraryReadResult> { |
| 111 | const raw = await readFileIfExists(getSessionMasterLayoutsPath(projectDir)) |
| 112 | if (raw === null) return { library: buildDefaultSessionLayoutLibrary(), exists: false } |
| 113 | try { |
| 114 | return { library: normalizeSessionLayoutLibrary(JSON.parse(raw)), exists: true } |
| 115 | } catch { |
| 116 | return { library: buildDefaultSessionLayoutLibrary(), exists: true } |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | export async function writeSessionLayoutLibrary( |
| 121 | projectDir: string, |
| 122 | value: unknown |
| 123 | ): Promise<SessionLayoutLibraryReadResult> { |
| 124 | const library = normalizeSessionLayoutLibrary(value) |
| 125 | await writeAtomically( |
| 126 | getSessionMasterLayoutsPath(projectDir), |
| 127 | `${JSON.stringify(library, null, 2)}\n` |
| 128 | ) |
| 129 | return { library, exists: true } |
| 130 | } |
| 131 | |
| 132 | export async function createSessionLayoutLibraryIfMissing( |
| 133 | projectDir: string |
| 134 | ): Promise<SessionLayoutLibraryReadResult> { |
| 135 | const existing = await readFileIfExists(getSessionMasterLayoutsPath(projectDir)) |
| 136 | if (existing !== null) return readSessionLayoutLibrary(projectDir) |
| 137 | return writeSessionLayoutLibrary(projectDir, buildDefaultSessionLayoutLibrary()) |
| 138 | } |
| 139 | |
| 140 | async function writeSessionMasterFiles( |
| 141 | projectDir: string, |
| 142 | value: unknown, |
| 143 | fontFaceCss = '' |
| 144 | ): Promise<SessionMasterReadResult> { |
| 145 | const config = normalizeMasterConfig(value) |
| 146 | const css = rebaseMasterAssetUrls(buildMasterCss(config, fontFaceCss)) |
| 147 | const html = buildMasterElementsHtml(config.elements) |
| 148 | await Promise.all([ |
| 149 | writeAtomically(getSessionMasterPath(projectDir), css), |
| 150 | writeAtomically(getSessionMasterHtmlPath(projectDir), html) |
| 151 | ]) |
| 152 | return { css, html, config, exists: true } |
| 153 | } |
| 154 | |
| 155 | export async function writeSessionMaster( |
| 156 | projectDir: string, |
| 157 | value: unknown, |
| 158 | fontFaceCss = '' |
| 159 | ): Promise<SessionMasterReadResult> { |
| 160 | return writeSessionMasterFiles(projectDir, value, fontFaceCss) |
| 161 | } |
| 162 | |
| 163 | /** |
| 164 | * This is only called while creating a fresh session copy, before its first |
| 165 | * history baseline. Runtime refresh and ordinary reads deliberately do not |
| 166 | * create master files. |
| 167 | */ |
| 168 | export async function createSessionMasterIfMissing(projectDir: string): Promise<SessionMasterReadResult> { |
| 169 | const canonicalCssPath = getSessionMasterPath(projectDir) |
| 170 | const canonicalHtmlPath = getSessionMasterHtmlPath(projectDir) |
| 171 | const [canonicalCss, canonicalHtml] = await Promise.all([ |
| 172 | readFileIfExists(canonicalCssPath), |
| 173 | readFileIfExists(canonicalHtmlPath) |
| 174 | ]) |
| 175 | |
| 176 | if (canonicalCss !== null && canonicalHtml !== null) { |
| 177 | await createSessionLayoutLibraryIfMissing(projectDir) |
| 178 | return toResult(canonicalCss, canonicalHtml, true) |
| 179 | } |
| 180 | |
| 181 | if (canonicalCss !== null) { |
| 182 | const config = parseMasterCss(unbaseMasterAssetUrls(canonicalCss)) |
| 183 | const html = canonicalHtml || buildMasterElementsHtml(config.elements) |
| 184 | if (canonicalHtml === null) await writeAtomically(canonicalHtmlPath, html) |
| 185 | await createSessionLayoutLibraryIfMissing(projectDir) |
| 186 | return toResult(canonicalCss, html, true) |
| 187 | } |
| 188 | |
| 189 | const result = await writeSessionMasterFiles(projectDir, buildDefaultMasterConfig()) |
| 190 | await createSessionLayoutLibraryIfMissing(projectDir) |
| 191 | return result |
| 192 | } |
| 193 |