diff --git a/Dockerfile b/Dockerfile index 32e92bf..29dea3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,10 @@ RUN npm ci --omit=dev && mkdir /app/screenshot-cache && chown node:node /app/scr COPY server.mjs ./server.mjs COPY screenshot-cache.mjs ./screenshot-cache.mjs COPY public ./public +RUN mkdir -p public/vendor/prism \ + && cp node_modules/prismjs/components/prism-core.min.js public/vendor/prism/ \ + && cp node_modules/prismjs/components/prism-asm6502.min.js public/vendor/prism/ \ + && cp node_modules/prismjs/LICENSE public/vendor/prism/LICENSE COPY . /programs ENV PORT=8080 diff --git a/package-lock.json b/package-lock.json index d1421b3..10660ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,6 +6,7 @@ "": { "name": "c64-program-collection", "dependencies": { + "prismjs": "1.30.0", "sharp": "0.35.5" } }, @@ -528,6 +529,15 @@ "node": ">=8" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", diff --git a/package.json b/package.json index 5c00031..c56c2d5 100644 --- a/package.json +++ b/package.json @@ -2,5 +2,5 @@ "name": "c64-program-collection", "private": true, "type": "module", - "dependencies": { "sharp": "0.35.5" } + "dependencies": { "sharp": "0.35.5", "prismjs": "1.30.0" } } diff --git a/public/app.js b/public/app.js index af0eab6..a19e7dc 100644 --- a/public/app.js +++ b/public/app.js @@ -207,7 +207,7 @@ async function showSource(program) { try { const response = await fetch(`/api/source?path=${encodeURIComponent(program.path)}`, { cache: 'no-store' }); if (!response.ok) throw new Error('The program source could not be loaded.'); - sourceCode.querySelector('code').textContent = await response.text(); + highlightSource(sourceCode.querySelector('code'), program, await response.text()); sourceStatus.hidden = true; sourceCode.hidden = false; } catch (error) { @@ -273,7 +273,7 @@ async function loadInlineSource(program) { try { const response = await fetch(`/api/source?path=${encodeURIComponent(program.path)}`, { cache: 'no-store' }); if (!response.ok) throw new Error('The program source could not be loaded.'); - listing.querySelector('code').textContent = await response.text(); + highlightSource(listing.querySelector('code'), program, await response.text()); message.hidden = true; listing.hidden = false; } catch (error) { diff --git a/public/index.html b/public/index.html index edf8098..b97c23a 100644 --- a/public/index.html +++ b/public/index.html @@ -90,6 +90,9 @@

Loading source…

+ + + diff --git a/public/source-highlighting.js b/public/source-highlighting.js new file mode 100644 index 0000000..48599ea --- /dev/null +++ b/public/source-highlighting.js @@ -0,0 +1,28 @@ +/* Prism grammars tailored to the sources in this collection. */ +if (window.Prism) { + Prism.languages['c64-basic'] = { + 'string': { pattern: /"[^"\r\n]*(?:"|(?=\r?$))/m, greedy: true }, + 'comment': { pattern: /\bREM[^\r\n]*/i, greedy: true }, + 'line-number': { pattern: /^\d+/m, alias: 'number' }, + 'keyword': /\b(?:CLOSE|CLR|CMD|CONT|DATA|DEF|DIM|END|FN|FOR|GET|GOSUB|GOTO|IF|INPUT|LET|LIST|LOAD|NEW|NEXT|ON|OPEN|POKE|PRINT|READ|RESTORE|RETURN|RUN|SAVE|STEP|STOP|SYS|THEN|TO|VERIFY|WAIT)\b/i, + 'function': /\b(?:ABS|ASC|ATN|CHR\$|COS|EXP|FRE|INT|LEFT\$|LEN|LOG|MID\$|PEEK|POS|RIGHT\$|RND|SGN|SIN|SPC|SQR|STR\$|TAB|TAN|USR|VAL)(?=\s*\()/i, + 'number': /(?:\b\d+(?:\.\d*)?|\B\.\d+)(?:E[+-]?\d+)?/i, + 'operator': /[+\-*/^=<>]|\b(?:AND|NOT|OR)\b/i, + 'punctuation': /[,;:()]/ + }; + Prism.languages['acme-6502'] = Prism.languages.extend('asm6502', { + 'directive': { pattern: /!\w+\b|\.\w+(?=\s)/, alias: 'property' }, + 'operator': /[+\-*/=<>#&|^~]/ + }); + Prism.languages.insertBefore('acme-6502', 'op-code', { + 'label': { pattern: /^\s*[a-z_]\w*(?=:)/im, alias: 'function' } + }); +} + +function highlightSource(element, program, source) { + const language = program.language === 'machine-code' ? 'acme-6502' : 'c64-basic'; + element.className = `language-${language}`; + // Assign text before highlighting, so source cannot inject HTML. + element.textContent = source; + if (window.Prism) Prism.highlightElement(element); +} diff --git a/public/styles.css b/public/styles.css index c2e79c9..9276ce2 100644 --- a/public/styles.css +++ b/public/styles.css @@ -85,6 +85,15 @@ button:disabled { opacity: .5; cursor: wait; } .instructions-panel h2, .program-source-panel h2 { margin: 0 0 .85rem; font-size: 1rem; letter-spacing: .08em; text-transform: uppercase; } .program-source-panel pre { max-height: 600px; margin: 0; padding: 1rem; overflow: auto; border-radius: 6px; background: #0d0928; color: #d8d3f4; font: .85rem/1.55 ui-monospace, SFMono-Regular, Consolas, monospace; white-space: pre; tab-size: 4; } .program-source-panel pre:focus-visible { outline: 2px solid var(--c64-light-blue); outline-offset: 2px; } +.program-source-panel code, .source-modal code { font: inherit; } +.token.comment { color: #aaa5b8; font-style: italic; } +.token.keyword { color: #c6b4ff; } +.token.string { color: #a6e3ad; } +.token.number { color: #f9c88b; } +.token.function { color: #8bd5ef; } +.token.property { color: #f5bde6; } +.token.variable { color: #eed49f; } +.token.operator, .token.punctuation { color: #cad3f5; } .control-list { display: grid; gap: .7rem; } .control-row { display: grid; grid-template-columns: minmax(150px, auto) 1fr; gap: 1rem; align-items: center; color: #d2cef0; } .key-group { display: flex; flex-wrap: wrap; gap: .35rem; }