From eeffa247bbd120cca202cc90021bdbab51027674 Mon Sep 17 00:00:00 2001 From: hugo Date: Fri, 20 Feb 2026 20:24:37 +0100 Subject: [PATCH] feat: style editor for blog --- src/main/engine/MetaEngine.ts | 8 + src/main/engine/PageRenderer.ts | 28 +++- src/main/engine/PreviewServer.ts | 82 ++++++++++- src/main/engine/templates/not-found.liquid | 4 +- .../engine/templates/partials/head.liquid | 3 +- .../engine/templates/partials/styles.liquid | 18 +-- src/main/engine/templates/post-list.liquid | 4 +- src/main/engine/templates/single-post.liquid | 4 +- src/main/ipc/handlers.ts | 2 +- src/main/preload.ts | 2 +- src/main/shared/electronApi.ts | 3 +- src/main/shared/picoThemes.ts | 50 +++++++ src/renderer/App.tsx | 7 + .../DocumentationView/DocumentationView.tsx | 16 +- src/renderer/components/Editor/Editor.tsx | 12 ++ src/renderer/components/Sidebar/Sidebar.tsx | 12 ++ .../components/StatusBar/StatusBar.css | 5 + .../components/StatusBar/StatusBar.tsx | 7 + .../components/StyleView/StyleView.css | 106 ++++++++++++++ .../components/StyleView/StyleView.tsx | 138 ++++++++++++++++++ src/renderer/components/StyleView/index.ts | 1 + src/renderer/components/TabBar/TabBar.tsx | 10 ++ src/renderer/components/index.ts | 1 + src/renderer/store/appStore.ts | 8 +- src/renderer/utils/picoTheme.ts | 70 +++++++++ tests/engine/MetaEngine.test.ts | 27 ++++ tests/engine/PreviewServer.test.ts | 56 ++++++- .../components/PagesShortcut.test.tsx | 22 +++ tests/renderer/components/StatusBar.test.tsx | 28 ++++ tests/renderer/components/StyleView.test.tsx | 78 ++++++++++ tests/renderer/components/TabBar.test.tsx | 11 ++ tests/renderer/documentationStructure.test.ts | 3 +- tests/renderer/store/tabStore.test.ts | 23 +++ 33 files changed, 817 insertions(+), 32 deletions(-) create mode 100644 src/main/shared/picoThemes.ts create mode 100644 src/renderer/components/StyleView/StyleView.css create mode 100644 src/renderer/components/StyleView/StyleView.tsx create mode 100644 src/renderer/components/StyleView/index.ts create mode 100644 src/renderer/utils/picoTheme.ts create mode 100644 tests/renderer/components/StatusBar.test.tsx create mode 100644 tests/renderer/components/StyleView.test.tsx diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 623aed3..cd416a0 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -5,6 +5,7 @@ import { app } from 'electron'; import { eq } from 'drizzle-orm'; import { getDatabase } from '../database'; import { posts, projects } from '../database/schema'; +import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes'; import { normalizeTaxonomyTerm, normalizeNonEmptyTaxonomyTerm, @@ -22,6 +23,7 @@ export interface ProjectMetadata { mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') defaultAuthor?: string; // Default author for new posts and media maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) + picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering } const DEFAULT_MAX_POSTS_PER_PAGE = 50; @@ -61,10 +63,12 @@ function sanitizePublicUrl(value: unknown): string | undefined { function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); const publicUrl = sanitizePublicUrl(metadata.publicUrl); + const picoTheme = sanitizePicoTheme(metadata.picoTheme); return { ...metadata, publicUrl, maxPostsPerPage, + picoTheme, }; } @@ -179,6 +183,9 @@ export class MetaEngine extends EventEmitter { if (updates.maxPostsPerPage !== undefined) { normalizedUpdates.maxPostsPerPage = sanitizeMaxPostsPerPage(updates.maxPostsPerPage); } + if (updates.picoTheme !== undefined) { + normalizedUpdates.picoTheme = sanitizePicoTheme(updates.picoTheme); + } if (!this.projectMetadata) { this.projectMetadata = normalizeProjectMetadata({ @@ -189,6 +196,7 @@ export class MetaEngine extends EventEmitter { mainLanguage: normalizedUpdates.mainLanguage, defaultAuthor: normalizedUpdates.defaultAuthor, maxPostsPerPage: normalizedUpdates.maxPostsPerPage, + picoTheme: normalizedUpdates.picoTheme, }); } else { this.projectMetadata = normalizeProjectMetadata({ diff --git a/src/main/engine/PageRenderer.ts b/src/main/engine/PageRenderer.ts index 44c1cc4..fa2440e 100644 --- a/src/main/engine/PageRenderer.ts +++ b/src/main/engine/PageRenderer.ts @@ -3,6 +3,7 @@ import { marked } from 'marked'; import { Liquid } from 'liquidjs'; import type { MediaData } from './MediaEngine'; import type { PostData } from './PostEngine'; +import { PICO_THEME_NAMES } from '../shared/picoThemes'; export interface HtmlRewriteContext { canonicalPostPathBySlug: Map; @@ -41,6 +42,8 @@ export type DateArchiveContext = { export interface PostListTemplateContext { page_title: string; language: string; + pico_stylesheet_href?: string; + html_theme_attribute?: string; is_date_archive: boolean; show_archive_range_heading: boolean; archive_context: { @@ -67,6 +70,8 @@ export interface PostListTemplateContext { export interface SinglePostTemplateContext { page_title: string; language: string; + pico_stylesheet_href?: string; + html_theme_attribute?: string; post: TemplatePostEntry; canonical_post_path_by_slug: Record; canonical_media_path_by_source_path: Record; @@ -75,6 +80,8 @@ export interface SinglePostTemplateContext { export interface NotFoundTemplateContext { page_title: string; language: string; + pico_stylesheet_href?: string; + html_theme_attribute?: string; } export interface RoutePagination { @@ -96,11 +103,20 @@ export interface PostEngineContract { getPost: (id: string) => Promise; } -export const PREVIEW_ASSETS = { +export const PREVIEW_ASSETS: Record = { 'pico.min.css': { modulePath: '@picocss/pico/css/pico.min.css', contentType: 'text/css; charset=utf-8', }, + ...Object.fromEntries( + PICO_THEME_NAMES.map((theme) => [ + `pico.${theme}.min.css`, + { + modulePath: `@picocss/pico/css/pico.${theme}.min.css`, + contentType: 'text/css; charset=utf-8', + }, + ]) + ), 'lightbox.min.css': { modulePath: 'lightbox2/dist/css/lightbox.min.css', contentType: 'text/css; charset=utf-8', @@ -109,7 +125,7 @@ export const PREVIEW_ASSETS = { modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js', contentType: 'application/javascript; charset=utf-8', }, -} as const; +}; export const PREVIEW_IMAGE_ASSETS = { 'prev.png': { @@ -616,6 +632,8 @@ export class PageRenderer { basePathname: string; page_title: string; language: string; + pico_stylesheet_href?: string; + html_theme_attribute?: string; pagination?: PaginationContext; }, ): PostListTemplateContext { @@ -710,6 +728,8 @@ export class PageRenderer { return { page_title: options.page_title, language: options.language, + pico_stylesheet_href: options.pico_stylesheet_href, + html_theme_attribute: options.html_theme_attribute, is_date_archive: options.routeKind === 'date', show_archive_range_heading: hasRangeHeading, archive_context: options.routeKind === 'date' @@ -763,6 +783,8 @@ export class PageRenderer { basePathname: string; page_title: string; language: string; + pico_stylesheet_href?: string; + html_theme_attribute?: string; pagination?: PaginationContext; }, postEngine?: PostEngineContract, @@ -786,7 +808,7 @@ export class PageRenderer { async renderSinglePost( post: PostData, rewriteContext: HtmlRewriteContext, - pageContext: { page_title: string; language: string }, + pageContext: { page_title: string; language: string; pico_stylesheet_href?: string; html_theme_attribute?: string }, postEngine?: PostEngineContract, ): Promise { const renderablePost = postEngine diff --git a/src/main/engine/PreviewServer.ts b/src/main/engine/PreviewServer.ts index c491731..4dfb9aa 100644 --- a/src/main/engine/PreviewServer.ts +++ b/src/main/engine/PreviewServer.ts @@ -18,6 +18,7 @@ import { type MediaEngineContract, type PostMediaEngineContract, } from './PageRenderer'; +import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes'; interface ActiveProjectContext { projectId: string; @@ -172,11 +173,25 @@ export class PreviewServer { const language = metadata?.mainLanguage?.trim() || 'en'; const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription); const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage); - const htmlRewriteContext = await this.buildHtmlRewriteContext(); - const requestUrl = new URL(req.url || '/', 'http://127.0.0.1'); + const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme')); + const previewThemeMode = sanitizePicoThemeMode(requestUrl.searchParams.get('mode')); + const appliedTheme = requestTheme ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme); + const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); + const htmlRewriteContext = await this.buildHtmlRewriteContext(); const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/'); + if (pathname === '/__style-preview') { + const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, { + pageTitle, + language, + picoStylesheetHref, + htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined, + }); + this.respond(res, 200, stylePreviewHtml); + return; + } + const asset = await this.resolveAsset(pathname); if (asset) { this.respondAsset(res, asset.contentType, asset.body); @@ -198,11 +213,15 @@ export class PreviewServer { const result = await this.resolveRoute(pathname, maxPostsPerPage, htmlRewriteContext, { pageTitle, language, + picoStylesheetHref, + htmlThemeAttribute: undefined, }); if (!result) { const notFoundHtml = await this.pageRenderer.renderNotFound({ page_title: '404 Not Found', language, + pico_stylesheet_href: picoStylesheetHref, + html_theme_attribute: undefined, }); this.respond(res, 404, notFoundHtml); return; @@ -219,7 +238,7 @@ export class PreviewServer { pathname: string, maxPostsPerPage: number, rewriteContext: HtmlRewriteContext, - pageContext: { pageTitle: string; language: string }, + pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, ): Promise { const routePagination = parseRoutePagination(pathname); if (!routePagination) { @@ -244,6 +263,8 @@ export class PreviewServer { return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -255,6 +276,8 @@ export class PreviewServer { return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -269,6 +292,8 @@ export class PreviewServer { return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -280,6 +305,8 @@ export class PreviewServer { return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -293,6 +320,8 @@ export class PreviewServer { pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -308,6 +337,8 @@ export class PreviewServer { pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -323,6 +354,8 @@ export class PreviewServer { pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -338,6 +371,8 @@ export class PreviewServer { return this.pageRenderer.renderSinglePost(post, rewriteContext, { page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -355,6 +390,8 @@ export class PreviewServer { pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -372,6 +409,8 @@ export class PreviewServer { pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -387,6 +426,8 @@ export class PreviewServer { pagination: { page, maxPostsPerPage, totalPosts: result.totalPosts }, page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } @@ -399,12 +440,45 @@ export class PreviewServer { return this.pageRenderer.renderSinglePost(pagePost, rewriteContext, { page_title: pageContext.pageTitle, language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, }, this.postEngine); } return null; } + private async renderStylePreview( + rewriteContext: HtmlRewriteContext, + pageContext: { pageTitle: string; language: string; picoStylesheetHref: string; htmlThemeAttribute?: string }, + ): Promise { + const result = await this.loadPublishedSnapshotsPage({ status: 'published' }, { + maxPostsPerPage: 10, + page: 1, + }); + + if (result.posts.length === 0) { + return this.pageRenderer.renderNotFound({ + page_title: pageContext.pageTitle, + language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }); + } + + return this.pageRenderer.renderPostList(result.posts, rewriteContext, { + archiveGrouping: true, + routeKind: 'date', + archiveContext: { kind: 'root' }, + basePathname: '/__style-preview', + pagination: { page: 1, maxPostsPerPage: 10, totalPosts: result.totalPosts }, + page_title: pageContext.pageTitle, + language: pageContext.language, + pico_stylesheet_href: pageContext.picoStylesheetHref, + html_theme_attribute: pageContext.htmlThemeAttribute, + }, this.postEngine); + } + private async findPublishedPostBySlug(slug: string, dateFilter?: { year: number; month: number }): Promise { if (!slug) return null; @@ -588,7 +662,7 @@ export class PreviewServer { const match = pathname.match(/^\/assets\/([^/]+)$/); if (!match) return null; - const assetName = match[1] as keyof typeof PREVIEW_ASSETS; + const assetName = match[1]; const assetDefinition = PREVIEW_ASSETS[assetName]; if (!assetDefinition) return null; diff --git a/src/main/engine/templates/not-found.liquid b/src/main/engine/templates/not-found.liquid index f744b44..1f51f27 100644 --- a/src/main/engine/templates/not-found.liquid +++ b/src/main/engine/templates/not-found.liquid @@ -1,6 +1,6 @@ - - {% render 'partials/head', page_title: page_title %} + + {% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
diff --git a/src/main/engine/templates/partials/head.liquid b/src/main/engine/templates/partials/head.liquid index 970b19d..d3b227b 100644 --- a/src/main/engine/templates/partials/head.liquid +++ b/src/main/engine/templates/partials/head.liquid @@ -2,7 +2,8 @@ {{ page_title }} - + {% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %} + {% render 'partials/styles' %} diff --git a/src/main/engine/templates/partials/styles.liquid b/src/main/engine/templates/partials/styles.liquid index e72def5..3a128d8 100644 --- a/src/main/engine/templates/partials/styles.liquid +++ b/src/main/engine/templates/partials/styles.liquid @@ -1,10 +1,10 @@