Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions include/layout.inc
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,11 @@ function get_nav_items(): array {
href: '/support.php',
id: 'help',
),
new NavItem(
name: 'Playground',
href: '/playground.php',
id: 'playground',
),
new NavItem(
name: 'PHP 8.5',
href: '/releases/8.5/index.php',
Expand Down
25 changes: 25 additions & 0 deletions js/ext/codemirror-php.mjs

Large diffs are not rendered by default.

118 changes: 118 additions & 0 deletions js/ext/codemirror-php.src.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* CodeMirror 6 wiring for the PHP Playground. This is the un-bundled source of
* codemirror-php.mjs, kept next to it so the bundle stays reproducible; the
* site itself has no frontend build step, so the bundle is committed.
*
* To rebuild:
* npm install codemirror@6 @codemirror/view@6 @codemirror/state@6 \
* @codemirror/language@6 @codemirror/commands@6 @codemirror/lang-php@6 \
* @lezer/highlight@1 esbuild
* npx esbuild codemirror-php.src.mjs --bundle --format=esm --minify \
* --outfile=codemirror-php.mjs
*
* The playground loads the bundle with a dynamic import() and falls back to a
* plain <textarea> when it is unavailable.
*/
import { EditorView, keymap } from '@codemirror/view';
import { EditorState, Prec } from '@codemirror/state';
import { basicSetup } from 'codemirror';
import { indentUnit, syntaxHighlighting, HighlightStyle } from '@codemirror/language';
import { indentWithTab } from '@codemirror/commands';
import { php } from '@codemirror/lang-php';
import { tags } from '@lezer/highlight';

/* The classic highlight_string() palette, sourced from the CSS custom
* properties so the editor and the rest of the page stay in step. */
const phpHighlight = HighlightStyle.define([
{
tag: [
tags.keyword,
tags.controlKeyword,
tags.operatorKeyword,
tags.definitionKeyword,
tags.moduleKeyword,
],
color: 'var(--hl-keyword)',
fontWeight: '500',
},
{ tag: [tags.atom, tags.bool], color: 'var(--hl-keyword)' },
{
tag: [tags.string, tags.special(tags.string), tags.character, tags.regexp, tags.escape],
color: 'var(--hl-string)',
},
{
tag: [tags.comment, tags.lineComment, tags.blockComment, tags.docComment],
color: 'var(--hl-comment)',
fontStyle: 'italic',
},
{
tag: [
tags.variableName,
tags.propertyName,
tags.definition(tags.variableName),
tags.function(tags.variableName),
tags.className,
tags.typeName,
tags.namespace,
tags.number,
],
color: 'var(--hl-default)',
},
{ tag: [tags.operator, tags.punctuation, tags.bracket], color: 'var(--hl-html)' },
{ tag: [tags.meta, tags.processingInstruction], color: 'var(--php-purple-dark)' },
{ tag: tags.tagName, color: 'var(--hl-default)' },
{ tag: tags.attributeName, color: 'var(--hl-default)' },
{ tag: tags.attributeValue, color: 'var(--hl-string)' },
]);

/* Editor chrome - tuned to the same surfaces the playground panes use. */
const phpTheme = EditorView.theme(
{
'&': {
height: '100%',
backgroundColor: 'var(--php-content)',
color: 'var(--hl-html)',
},
'.cm-scroller': {
fontFamily: 'var(--font-family-mono)',
fontSize: '14px',
lineHeight: '1.55',
},
'.cm-gutters': {
backgroundColor: 'var(--php-code-bg)',
color: '#aab',
border: 'none',
borderRight: '1px solid var(--php-border)',
},
'.cm-activeLine': { backgroundColor: '#f6f7fb' },
'.cm-activeLineGutter': { backgroundColor: '#eef0f7' },
'&.cm-focused': { outline: 'none' },
'&.cm-focused .cm-cursor': { borderLeftColor: 'var(--php-purple-dark)' },
'.cm-matchingBracket, &.cm-focused .cm-matchingBracket': {
backgroundColor: 'transparent',
color: 'var(--php-purple-dark)',
fontWeight: '600',
},
},
{ dark: false },
);

const extensions = [
basicSetup,
php(), // PHP embedded in HTML
Prec.highest(syntaxHighlighting(phpHighlight)), // win over basicSetup's default
indentUnit.of(' '),
keymap.of([indentWithTab]),
phpTheme,
];

// One EditorState per file - each carries its own document and undo history.
export function makeState(text) {
return EditorState.create({ doc: text, extensions });
}

export function createView(parent) {
return new EditorView({ state: makeState(''), parent });
}

export { EditorView };
149 changes: 149 additions & 0 deletions js/playground-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/*
* Playground execution worker: runs the editor's files through the PHP
* WebAssembly build that also powers the manual's interactive examples
* (/js/php-web.mjs + /js/php-web.wasm).
*
* Protocol (main -> worker): { files: [{name, content}], entry }
* Protocol (worker -> main): { type: 'stdout', text }
* { type: 'done', ms }
* { type: 'fatal', text, ms? }
*/

if (typeof self.window === 'undefined') {
// Parts of the Emscripten loader expect a window global.
self.window = self;
}

// The directory the editor's files are materialised in. The main thread strips
// this prefix back off filenames in diagnostics, so traces read as "lib.php"
// rather than "/playground/lib.php".
const WORKDIR = '/playground';

const PHP_MODULE_URL = '/js/php-web.mjs';
const PHP_WASM_URL = '/js/php-web.wasm';

let createPhpModule = null;

async function loadFactory() {
if (!createPhpModule) {
const phpModule = await import(PHP_MODULE_URL);
createPhpModule = phpModule.default;
}
return createPhpModule;
}

// Compile php-web.wasm exactly once and reuse the resulting WebAssembly.Module
// across every run. Each run still instantiates a fresh module (fresh memory,
// globals and filesystem), but the expensive fetch + compile happens a single
// time instead of on every Run click.
let wasmModulePromise = null;

function getWasmModule() {
if (!wasmModulePromise) {
wasmModulePromise = WebAssembly.compileStreaming(fetch(PHP_WASM_URL)).catch(() => {
// Dev servers that send the wrong MIME type (not application/wasm)
// break compileStreaming; fall back to compiling the raw bytes.
return fetch(PHP_WASM_URL)
.then((response) => response.arrayBuffer())
.then((bytes) => WebAssembly.compile(bytes));
});
}
return wasmModulePromise;
}

self.onmessage = async (event) => {
const data = event.data || {};
const files = Array.isArray(data.files) ? data.files : [];
const entry = data.entry || files[0]?.name;
const started = performance.now();

let factory;
try {
factory = await loadFactory();
} catch (error) {
self.postMessage({
type: 'fatal',
text: 'Could not load the PHP WebAssembly build (/js/php-web.mjs).\n\n'
+ String(error?.message || error),
});
return;
}

if (!entry) {
self.postMessage({ type: 'fatal', text: 'No entry file to run.' });
return;
}

try {
// Fresh instance per run: a brand-new module guarantees a clean
// filesystem and clean global state.
const Module = await factory({
print: (text) => self.postMessage({ type: 'stdout', text }),
printErr: (text) => self.postMessage({ type: 'stdout', text }),
// Reuse the once-compiled module instead of letting Emscripten fetch
// and recompile php-web.wasm on every instantiation.
instantiateWasm: (imports, successCallback) => {
getWasmModule()
.then((wasmModule) => WebAssembly.instantiate(wasmModule, imports))
.then((instance) => successCallback(instance))
.catch((error) => {
self.postMessage({
type: 'fatal',
text: 'Failed to instantiate the PHP WebAssembly module.\n'
+ String(error?.message || error),
});
});
return {}; // tells Emscripten the instantiation is async
},
});

writeFiles(Module.FS, files);
Module.FS.chdir(WORKDIR);
Module.ccall('phpw', null, ['string'], [WORKDIR + '/' + sanitizeFileName(entry)]);

self.postMessage({ type: 'done', ms: Math.round(performance.now() - started) });
} catch (error) {
// A WASM abort (e.g. out of memory, or a fatal that calls exit()) lands here.
self.postMessage({
type: 'fatal',
text: String(error?.message || error),
ms: Math.round(performance.now() - started),
});
}
};

function writeFiles(FS, files) {
mkdirp(FS, WORKDIR);
for (const file of files) {
if (!file || !file.name) {
continue;
}
const path = WORKDIR + '/' + sanitizeFileName(file.name);
mkdirp(FS, dirname(path));
FS.writeFile(path, file.content == null ? '' : String(file.content));
}
}

function dirname(path) {
const lastSlash = path.lastIndexOf('/');
return lastSlash <= 0 ? '/' : path.slice(0, lastSlash);
}

function mkdirp(FS, path) {
let current = '';
for (const part of path.split('/').filter(Boolean)) {
current += '/' + part;
try {
FS.mkdir(current);
} catch (error) {
/* EEXIST - fine. */
}
}
}

function sanitizeFileName(name) {
return String(name)
.split('/')
.filter((segment) => segment && segment !== '.' && segment !== '..')
.join('/');
}
Loading