feat: source highlighting for code blocks
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
137
src/main/engine/assets/codeEnhancementsRuntime.ts
Normal file
137
src/main/engine/assets/codeEnhancementsRuntime.ts
Normal 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();
|
||||
}
|
||||
})();
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user