feat: source highlighting for code blocks

This commit is contained in:
2026-02-22 13:24:41 +01:00
parent ac75bcb1ac
commit b67ffbd38a
10 changed files with 432 additions and 47 deletions

View File

@@ -307,6 +307,8 @@ export class BlogGenerationEngine {
let sitemapWritten = false;
let rssWritten = false;
let atomWritten = false;
const generatedHashCache = new Map<string, string | null>();
const knownOutputDirectories = new Set<string>();
if (includeCore) {
onProgress(10, 'Writing sitemap and feeds...');
@@ -333,7 +335,10 @@ export class BlogGenerationEngine {
reportUnitProgress('Atom feed written');
onProgress(15, 'Copying assets...');
await copyPreviewAssets(htmlDir);
await copyPreviewAssets(htmlDir, {
projectId: options.projectId,
hashCache: generatedHashCache,
});
reportUnitProgress('Assets copied');
}
@@ -348,9 +353,6 @@ export class BlogGenerationEngine {
},
});
const knownOutputDirectories = new Set<string>();
const generatedHashCache = new Map<string, string | null>();
const writePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
projectId,
htmlDir,

View File

@@ -4,6 +4,25 @@ import * as path from 'node:path';
import { getGeneratedFileHash, setGeneratedFileHash } from '../database/generatedFileHashStore';
import { PREVIEW_ASSETS, PREVIEW_IMAGE_ASSETS } from './PageRenderer';
type PreviewAssetMap = Record<string, { contentType: string; modulePath?: string; sourceText?: string }>;
type PreviewImageAssetMap = Record<string, { modulePath: string; contentType: string }>;
function toBuffer(value: unknown): Buffer {
if (Buffer.isBuffer(value)) {
return value;
}
if (typeof value === 'string') {
return Buffer.from(value, 'utf-8');
}
if (value instanceof Uint8Array) {
return Buffer.from(value);
}
return Buffer.alloc(0);
}
export function normalizeGeneratedUrlPath(urlPath: string): string {
const trimmed = (urlPath || '').trim();
if (!trimmed || trimmed === '/') {
@@ -28,6 +47,10 @@ export function computeContentHash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
export function computeBufferHash(content: Buffer): string {
return crypto.createHash('sha256').update(content).digest('hex');
}
export async function writeFileIfHashChanged(params: {
projectId: string;
filePath: string;
@@ -104,24 +127,67 @@ export async function writeHtmlPage(params: {
});
}
export async function copyPreviewAssets(htmlDir: string): Promise<void> {
export async function copyPreviewAssets(htmlDir: string, options?: {
projectId?: string;
hashCache?: Map<string, string | null>;
previewAssets?: PreviewAssetMap;
previewImageAssets?: PreviewImageAssetMap;
readModuleFile?: (modulePath: string) => Promise<Buffer>;
getGeneratedFileHash?: (projectId: string, relativePath: string) => Promise<string | null>;
setGeneratedFileHash?: (projectId: string, relativePath: string, hash: string) => Promise<void>;
}): Promise<void> {
const assetsDir = path.join(htmlDir, 'assets');
const imagesDir = path.join(htmlDir, 'images');
await fs.mkdir(assetsDir, { recursive: true });
await fs.mkdir(imagesDir, { recursive: true });
for (const [filename, definition] of Object.entries(PREVIEW_ASSETS)) {
const projectId = options?.projectId;
const hashCache = options?.hashCache;
const readModuleFile = options?.readModuleFile ?? (async (modulePath: string) => toBuffer(await fs.readFile(require.resolve(modulePath))));
const getHash = options?.getGeneratedFileHash ?? getGeneratedFileHash;
const setHash = options?.setGeneratedFileHash ?? setGeneratedFileHash;
const previewAssets = options?.previewAssets ?? PREVIEW_ASSETS;
const previewImageAssets = options?.previewImageAssets ?? PREVIEW_IMAGE_ASSETS;
const writeBinaryIfHashChanged = async (filePath: string, relativePath: string, content: Buffer): Promise<void> => {
if (!projectId) {
await fs.writeFile(filePath, content);
return;
}
const hash = computeBufferHash(content);
let previousHash: string | null;
if (hashCache && hashCache.has(relativePath)) {
previousHash = hashCache.get(relativePath) ?? null;
} else {
previousHash = await getHash(projectId, relativePath);
hashCache?.set(relativePath, previousHash);
}
if (previousHash === hash) {
return;
}
await fs.writeFile(filePath, content);
await setHash(projectId, relativePath, hash);
hashCache?.set(relativePath, hash);
};
for (const [filename, definition] of Object.entries(previewAssets)) {
const destPath = path.join(assetsDir, filename);
const relativePath = path.posix.join('assets', filename);
const content = definition.sourceText !== undefined
? Buffer.from(definition.sourceText, 'utf-8')
: await fs.readFile(require.resolve(definition.modulePath as string));
await fs.writeFile(destPath, content);
: toBuffer(await readModuleFile(definition.modulePath as string));
await writeBinaryIfHashChanged(destPath, relativePath, content);
}
for (const [filename, definition] of Object.entries(PREVIEW_IMAGE_ASSETS)) {
const sourcePath = require.resolve(definition.modulePath);
for (const [filename, definition] of Object.entries(previewImageAssets)) {
const destPath = path.join(imagesDir, filename);
const content = await fs.readFile(sourcePath);
await fs.writeFile(destPath, content);
const relativePath = path.posix.join('images', filename);
const content = toBuffer(await readModuleFile(definition.modulePath));
await writeBinaryIfHashChanged(destPath, relativePath, content);
}
}

View File

@@ -5,6 +5,7 @@ import type { MediaData } from './MediaEngine';
import type { PostData } from './PostEngine';
import type { MenuDocument, MenuItemData } from './MenuEngine';
import { PICO_THEME_NAMES } from '../shared/picoThemes';
import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime';
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
@@ -135,6 +136,29 @@ export interface PreviewAssetDefinition {
sourceText?: string;
}
function annotateCodeBlocksWithLanguage(html: string): string {
if (!html) {
return html;
}
return html.replace(/<code\b([^>]*)>/gi, (fullMatch, rawAttributes: string) => {
if (/\bdata-code-language\s*=/.test(rawAttributes)) {
return fullMatch;
}
const classMatch = rawAttributes.match(/\bclass\s*=\s*"([^"]*)"/i);
const classList = classMatch?.[1] ?? '';
const languageMatch = classList.match(/(?:^|\s)language-([\w.+-]+)(?:\s|$)/i);
const language = languageMatch?.[1]?.toLowerCase();
if (!language) {
return fullMatch;
}
return `<code${rawAttributes} data-code-language="${escapeHtml(language)}">`;
});
}
export interface TagUsageEntry {
tag: string;
count: number;
@@ -178,6 +202,18 @@ export const PREVIEW_ASSETS: Record<string, PreviewAssetDefinition> = {
modulePath: 'lightbox2/dist/js/lightbox-plus-jquery.min.js',
contentType: 'application/javascript; charset=utf-8',
},
'highlight.min.css': {
modulePath: '@highlightjs/cdn-assets/styles/github-dark.min.css',
contentType: 'text/css; charset=utf-8',
},
'highlight.min.js': {
modulePath: '@highlightjs/cdn-assets/highlight.min.js',
contentType: 'application/javascript; charset=utf-8',
},
'code-enhancements.js': {
contentType: 'application/javascript; charset=utf-8',
sourceText: CODE_ENHANCEMENTS_RUNTIME_JS,
},
'd3.layout.cloud.js': {
modulePath: 'd3-cloud/build/d3.layout.cloud.js',
contentType: 'application/javascript; charset=utf-8',
@@ -829,7 +865,8 @@ export class PageRenderer {
});
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
return rewriteRenderedHtmlUrls(markdownHtml, rewriteContext);
const annotatedMarkdownHtml = annotateCodeBlocksWithLanguage(markdownHtml);
return rewriteRenderedHtmlUrls(annotatedMarkdownHtml, rewriteContext);
});
}

View File

@@ -0,0 +1,137 @@
export const CODE_ENHANCEMENTS_RUNTIME_JS = String.raw`(function () {
function resolveCodeLanguage(codeElement) {
if (!codeElement) {
return '';
}
var direct = codeElement.getAttribute('data-code-language');
if (typeof direct === 'string' && direct.trim()) {
return direct.trim().toLowerCase();
}
var className = codeElement.className || '';
var classMatch = className.match(/(?:^|\s)language-([\w.+-]+)/i);
if (classMatch && classMatch[1]) {
return classMatch[1].toLowerCase();
}
return '';
}
function fallbackCopy(value) {
var textarea = document.createElement('textarea');
textarea.value = value;
textarea.setAttribute('readonly', 'readonly');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
textarea.style.pointerEvents = 'none';
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
try {
return document.execCommand('copy');
} catch {
return false;
} finally {
document.body.removeChild(textarea);
}
}
async function copyCodeToClipboard(value) {
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(value);
return true;
} catch {
return fallbackCopy(value);
}
}
return fallbackCopy(value);
}
function ensureCopyButton(preElement, codeElement) {
if (!preElement || preElement.querySelector(':scope > .code-copy-button')) {
return;
}
preElement.classList.add('code-block-enhanced');
var button = document.createElement('button');
button.type = 'button';
button.className = 'code-copy-button';
button.setAttribute('aria-hidden', 'true');
var icon = document.createElement('span');
icon.className = 'code-copy-icon';
icon.textContent = '⧉';
button.appendChild(icon);
button.addEventListener('click', async function () {
var codeText = codeElement.textContent || '';
var copied = await copyCodeToClipboard(codeText);
preElement.classList.remove('code-copy-failed');
preElement.classList.remove('code-copy-success');
preElement.classList.add(copied ? 'code-copy-success' : 'code-copy-failed');
if (copied) {
icon.textContent = '✓';
window.setTimeout(function () {
preElement.classList.remove('code-copy-success');
icon.textContent = '⧉';
}, 1200);
return;
}
window.setTimeout(function () {
preElement.classList.remove('code-copy-failed');
}, 1200);
});
preElement.appendChild(button);
}
function highlightCodeBlock(codeElement) {
var highlighter = window.hljs;
if (!highlighter || typeof highlighter.highlightElement !== 'function') {
return;
}
if (codeElement.getAttribute('data-code-highlighted') === 'true') {
return;
}
try {
highlighter.highlightElement(codeElement);
codeElement.setAttribute('data-code-highlighted', 'true');
} catch {
}
}
function initCodeBlocks() {
var codeNodes = document.querySelectorAll('pre > code');
codeNodes.forEach(function (codeElement) {
var preElement = codeElement.parentElement;
if (!preElement || preElement.tagName !== 'PRE') {
return;
}
var language = resolveCodeLanguage(codeElement);
if (language) {
codeElement.setAttribute('data-code-language', language);
preElement.setAttribute('data-code-language', language);
}
ensureCopyButton(preElement, codeElement);
highlightCodeBlock(codeElement);
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCodeBlocks, { once: true });
} else {
initCodeBlocks();
}
})();
`;

View File

@@ -5,9 +5,12 @@
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
<link rel="stylesheet" href="/assets/lightbox.min.css" />
<link rel="stylesheet" href="/assets/highlight.min.css" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
{% render 'partials/styles' %}
<script defer src="/assets/highlight.min.js"></script>
<script defer src="/assets/code-enhancements.js"></script>
<script defer src="/assets/d3.layout.cloud.js"></script>
<script defer src="/assets/tag-cloud.js"></script>
<script defer src="/assets/lightbox.min.js"></script>

View File

@@ -18,6 +18,30 @@
.blog-menu-item-with-children:hover > .blog-menu-submenu,
.blog-menu-item-with-children:focus-within > .blog-menu-submenu { display: block; }
.post { border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); padding: 1rem; background: var(--pico-card-background-color, var(--card-background-color)); }
.post pre { position: relative; overflow-x: auto; border: 1px solid var(--pico-muted-border-color, var(--muted-border-color)); border-radius: .3rem; margin: .9rem 0; padding: .85rem .9rem; background: var(--pico-code-background-color, rgba(33, 38, 45, .82)); }
.post pre code { display: block; font-size: .88rem; line-height: 1.5; }
.code-copy-button {
position: absolute;
top: .4rem;
right: .4rem;
border: 1px solid var(--pico-muted-border-color, var(--muted-border-color));
background: var(--pico-card-background-color, var(--card-background-color));
color: var(--pico-muted-color, var(--muted-color));
border-radius: .25rem;
width: 1.8rem;
height: 1.8rem;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: pointer;
opacity: .88;
}
.code-copy-button:hover,
.code-copy-button:focus-visible { opacity: 1; color: var(--pico-color, var(--color)); }
.code-copy-icon { font-size: .95rem; line-height: 1; }
.code-copy-success .code-copy-button { color: var(--pico-ins-color, rgb(53, 117, 56)); border-color: var(--pico-ins-color, rgb(53, 117, 56)); }
.code-copy-failed .code-copy-button { color: var(--pico-del-color, rgb(183, 72, 72)); border-color: var(--pico-del-color, rgb(183, 72, 72)); }
.post iframe { width: 100%; min-height: 20rem; }
.macro-gallery, .macro-photo-archive, .macro-tag-cloud { border: 1px dashed var(--pico-muted-border-color, var(--muted-border-color)); padding: .75rem; margin: 1rem 0; }
.gallery-container { display: grid; gap: .5rem; }