Reference - @xxx@ constructs - page


App: admin - Page: login
PHP
login.php
HTML
login.pad
Result
<?php

// The login - and, while DATA/admin/users.json holds nobody, the making of the first user,
// which only this machine may do: there is no default password to guess. Five tries a
// minute per address. The password is read as it was posted: a space at its end is part
// of it.

$title = 'Log in';
$loginSetup = adminUsers () ? 0 : 1;
$loginError = '';
$loginHint = ( $loginSetup and PHP_SAPI !== 'cli' and ! padLoopback () ) ? 1 : 0;

if (
adminUserValid ( $adminUser, $adminStamp ) )
padRedirect ( 'index' );

if (
padPosted ( 'login' ) ) {

$loginKey = 'admin-login:' . ( $_SERVER ['REMOTE_ADDR'] ?? 'cli' );

if ( !
padRateLimit ( $loginKey, 5, 60 ) ) {

$loginError = 'Too many tries - wait ' . padRateLimitAvailableIn ( $loginKey ) . ' seconds.';

} else {

$loginName = trim ( adminField ( 'name' ) );
$loginPassword = adminField ( 'password' );
$loginStamp = '';

try {

if ( !
$loginSetup )
$loginStamp = adminUserCheck ( $loginName, $loginPassword );
elseif (
$loginHint )
$loginError = 'The first user can only be made from this machine.';
elseif (
$loginPassword !== adminField ( 'again' ) )
$loginError = 'The two passwords are not the same.';
else
$loginStamp = adminUserAdd ( $loginName, $loginPassword );

} catch (
RuntimeException $e ) {

$loginError = ucfirst ( $e->getMessage () ) . '.';

}

if (
$loginStamp !== '' ) {

padRateLimitClear ( $loginKey );
padSessionRegenerate ();

$adminUser = $loginName;
$adminStamp = $loginStamp;

padRedirect ( 'index' );

}

if (
$loginError === '' )
$loginError = 'The name or the password is not right.';

}

}

?>
<div class="login">

{if $loginSetup eq 1}
<p class="dim">Nobody can log in yet. Make the first user - more can be added in the console later.</p>
{else}
<p class="dim">The console of the PAD installation on this server.</p>
{/if}

{if $loginHint eq 1}
<p class="flash error" role="alert">The first user can only be made from this machine.</p>
{/if}

{if $loginError ne ''}
<p class="flash error" role="alert">{$loginError}</p>
{/if}

{form 'login'}
<p>{input 'name', label='Name', autocomplete='username', required, autofocus}</p>
{if $loginSetup eq 1}
<p>{input 'password', type='password', label='Password', autocomplete='new-password', required, minlength=8}</p>
<p>{input 'again', type='password', label='Password again', autocomplete='new-password', required, minlength=8}</p>
<p><button type="submit" class="primary">Make the user and log in</button></p>
{else}
<p>{input 'password', type='password', label='Password', autocomplete='current-password', required}</p>
<p><button type="submit" class="primary">Log in</button></p>
{/if}
{/form}

</div>


App: alpine - Page: examples/components
PHP
examples/components.php
HTML
examples/components.pad
Result
<?php

// The data of the components: plain arrays here, a database or a _data file anywhere else.
// The template hands each to its component as the argument - x-data="accordion({^faq})" -
// and renders the same rows as HTML inside it, for a visitor without JavaScript.

$faq = [ [ 'q' => 'Why not write the state in the template?',
'a' => 'Alpine writes objects with braces, and PAD reads every brace of a template as the start of a tag. Let PAD write the JSON instead.' ],
[
'q' => 'Where do the components live?',
'a' => 'In www/alpine/examples/components.js - a file the web server sends as it is, so its braces never meet the template parser.' ],
[
'q' => 'Does it work without JavaScript?',
'a' => 'The answers are in the HTML PAD wrote; x-cloak and x-show only fold them away once Alpine runs.' ] ];

$tabs = [ [ 'key' => 'server', 'label' => 'Server', 'text' => 'PAD renders the page, with every value escaped for where it stands.' ],
[
'key' => 'wire', 'label' => 'Wire', 'text' => 'The state crosses as JSON in an attribute - no second request.' ],
[
'key' => 'client', 'label' => 'Client', 'text' => 'Alpine reads it and keeps the component live in the browser.' ] ];

?>
<div class="cols-2">

<section>
<div class="panel-title">Accordion - accordion({^faq})</div>
<div class="accordion" x-data="accordion({^faq})">
{faq}
<div class="accordion-item" :class="isOpen({current@faq}) && 'is-open'">
<button class="accordion-head" @click="toggle({current@faq})" :aria-expanded="isOpen({current@faq})">{$q}<span aria-hidden="true">+</span></button>
<div class="accordion-body" x-show="isOpen({current@faq})" x-transition>{$a}</div>
</div>
{/faq}
</div>
</section>

<section>
<div class="panel-title">Tabs - tabs({^tabs})</div>
<div class="tabs" x-data="tabs({^tabs})">
<div class="pills" role="tablist">
<template x-for="tab in list" :key="tab.key">
<button class="pill" role="tab" :class="tab.key === active && 'is-on'" :aria-selected="tab.key === active" @click="active = tab.key" x-text="tab.label"></button>
</template>
</div>
<p class="panel tab-text" x-text="current().text">{$tabs.0.text}</p>
</div>

<div class="panel-title" style="margin-top: 26px">Dialog - dialog()</div>
<div x-data="dialog()">
<button @click="show()">Open the dialog</button>
<div class="backdrop" x-show="open" x-transition.opacity x-cloak @click.self="hide()" @keydown.escape.window="hide()">
<div class="dialog" role="dialog" aria-modal="true" aria-label="A dialog" x-show="open" x-transition>
<h3>Rendered by PAD, shown by Alpine</h3>
<p class="muted">This box was in the page from the start; Alpine only decides when it shows.</p>
<button class="btn-ghost" @click="hide()" x-ref="close">Close</button>
</div>
</div>
</div>
</section>

</div>

<script src="{$padRoot}alpine/examples/components.js"></script>


App: alpine - Page: examples/form
PHP
examples/form.php
HTML
examples/form.pad
Result
<?php

// One list of rules for both sides. padValidate checks a post against it; for the browser
// padValidateClient exports the same list, every rule with the message padValidate would
// give, and {validator} in the template brings the checker that reads it. A good post is
// kept in the session; the answer is JSON either way - the messages, or the new list.

$rules = [ 'name' => 'required|max:40',
'email' => 'required|email',
'team' => 'required|in:web,platform,data',
'seats' => 'required|integer|min:1|max:8' ];

$people = padSession ( 'alpinePeople', [] );
$errors = [];
$saved = FALSE;

if (
padRequestIs ( 'POST' ) ) {

$errors = padValidate ( $rules );

if ( !
$errors ) {
array_unshift ( $people, [ 'name' => trim ( padRequest ( 'name' ) ), 'team' => padRequest ( 'team' ), 'seats' => (int) padRequest ( 'seats' ) ] );
$saved = padSessionPut ( 'alpinePeople', array_slice ( $people, 0, 8 ) );
$people = array_slice ( $people, 0, 8 );
}

}

$start = [ 'rules' => padValidateClient ( $rules ), 'people' => $people ];
$padExpose = [ 'saved', 'errors', 'people' ];

?>
<div class="cols-2" x-data="signup({^start})">

<form novalidate @submit.prevent="send()" class="stack-fields">
<label class="label">Name <input class="input" name="name" x-model="form.name" @blur="leave('name')" :class="errors.name && 'is-invalid'"></label>
<span class="error" x-text="errors.name" x-show="errors.name" x-cloak></span>

<label class="label">E-mail <input class="input" name="email" type="email" x-model="form.email" @blur="leave('email')" :class="errors.email && 'is-invalid'"></label>
<span class="error" x-text="errors.email" x-show="errors.email" x-cloak></span>

<label class="label">Team
<select class="input" name="team" x-model="form.team" @change="leave('team')" :class="errors.team && 'is-invalid'">
<option value="">Choose ...</option>
<option value="web">Web</option>
<option value="platform">Platform</option>
<option value="data">Data</option>
<option value="sales">Sales - not one of the rules</option>
</select>
</label>
<span class="error" x-text="errors.team" x-show="errors.team" x-cloak></span>

<label class="label">Seats <input class="input" name="seats" x-model="form.seats" @blur="leave('seats')" :class="errors.seats && 'is-invalid'"></label>
<span class="error" x-text="errors.seats" x-show="errors.seats" x-cloak></span>

<label class="small check-line"><input type="checkbox" x-model="browser"> Check in the browser first</label>
<button :disabled="busy">Book</button>
<p class="small" x-text="note" x-show="note" x-cloak></p>
</form>

<div>
<div class="panel-title">Booked in your session</div>
<template x-for="(one, at) in people" :key="at">
<div class="card booked"><strong x-text="one.name"></strong> <span class="badge" x-text="one.team"></span> <span class="muted small" x-text="one.seats + ' seats'"></span></div>
</template>
<p class="muted small" x-show="people.length === 0">Nobody yet.</p>
</div>

</div>

{validator}
<script src="{$padRoot}alpine/examples/form.js"></script>


App: alpine - Page: examples/live
PHP
examples/live.php
HTML
examples/live.pad
Result
<?php

// ?examples/live&stream is the stream: padSse calls the producer every second for half a
// minute and sends what it answers as a 'stats' event - JSON - then a 'done' event; the
// page itself, asked without stream, is the board the numbers come into.

if ( padRequestHas ( 'stream' ) ) {

$round = 0;

padSse ( function ( $send ) use ( &$round ) {

$round++;

if (
$round > 30 ) {
$send ( 'done', 'done' );
return
FALSE;
}

$load = function_exists ( 'sys_getloadavg' ) ? round ( sys_getloadavg () [0], 2 ) : 0;

return [
'round' => $round, 'time' => date ( 'H:i:s' ), 'load' => $load,
'memory' => round ( memory_get_usage () / 1024 ) ];

}, [
'every' => 1, 'for' => 35, 'event' => 'stats', 'retry' => 5000 ] );

}

?>
<div x-data="live('?examples/live&stream')" x-init="open()">

<div class="toolbar">
<span class="badge" :class="state === 'open' ? 'is-good' : 'is-bad'" x-text="state === 'open' ? 'receiving' : state"></span>
<button class="btn-sm" @click="open()" x-show="state !== 'open'" x-cloak>Listen again</button>
<button class="btn-sm btn-ghost" @click="close()" x-show="state === 'open'" x-cloak>Stop</button>
</div>

<div class="grid tiles">
<div class="panel"><div class="panel-title">Server time</div><div class="tile" x-text="last.time ?? '-'"></div></div>
<div class="panel"><div class="panel-title">Load</div><div class="tile" x-text="last.load ?? '-'"></div></div>
<div class="panel"><div class="panel-title">Memory, kB</div><div class="tile" x-text="last.memory ?? '-'"></div></div>
<div class="panel"><div class="panel-title">Events</div><div class="tile" x-text="count"></div></div>
</div>

<div class="panel spark">
<div class="panel-title">Load, the last thirty seconds</div>
<svg viewBox="0 0 300 60" preserveAspectRatio="none" aria-hidden="true"><polyline :points="points()" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke"></polyline></svg>
</div>
</div>

<script src="{$padRoot}alpine/examples/live.js"></script>


App: alpine - Page: examples/search
PHP
examples/search.php
HTML
examples/search.pad
Result
<?php

// The page renders the first results into the component's argument; every later query
// asks the same page with padFormat=json, and the same PHP answers - the templates never
// run for that request, and only $result leaves the server ($padExpose).

$q = trim ( alpineText ( 'q' ) );

$found = array_values ( array_filter ( alpineData ( 'products' ), fn ( $product ) =>
$q === '' or stripos ( $product ['name'] . ' ' . $product ['category'] . ' ' . $product ['blurb'], $q ) !== FALSE ) );

$result = [ 'q' => $q, 'count' => count ( $found ), 'items' => array_slice ( $found, 0, 8 ) ];

$padExpose = [ 'result' ];

?>
<div x-data="search({^result})">

<div class="toolbar">
<div class="search">
<input class="input" type="search" x-model.debounce.250ms="q" @input.debounce.250ms="ask()" placeholder="Search - try lamp, audio or usb" aria-label="Search the products">
</div>
<span class="spinner" x-show="busy" x-cloak aria-hidden="true"></span>
</div>

<p class="muted small"><span x-text="count">{$result.count}</span> found - the first eight shown</p>

<div class="grid results">
<template x-for="item in items" :key="item.id">
<div class="card result">
<span class="result-emoji" x-text="item.emoji"></span>
<div>
<strong x-text="item.name"></strong>
<div class="muted small" x-text="item.category + ' · € ' + item.price.toFixed(2)"></div>
</div>
</div>
</template>
</div>

<p class="empty-state" x-show="count === 0" x-cloak>Nothing in the catalogue matches.</p>
</div>

<script src="{$padRoot}alpine/examples/search.js"></script>


App: alpine - Page: examples/state
PHP
examples/state.php
HTML
examples/state.pad
Result
<?php

// The state of the card: an array of the page's PHP, written into x-data as JSON by the ^
// sigil - escaped for the attribute, so a quote or a brace in a value stays text. Alpine
// reads it as the object it is and keeps it live; nothing goes back to the server.

$profile = [ 'name' => 'Ada Fernández',
'role' => 'Lead engineer',
'city' => 'Amsterdam',
'bio' => 'Builds things that render on the server and wake up in the browser.',
'likes' => 12,
'liked' => FALSE,
'skills' => [ 'PHP', 'PAD', 'Alpine', 'SQL' ],
'skill' => '' ];

?>
<div class="cols-2">

<div class="card profile" x-data="{^profile}">
<div class="profile-head">
<span class="avatar">AF</span>
<div>
<h3 x-text="name">{$profile.name}</h3>
<div class="muted small"><span x-text="role">{$profile.role}</span> &middot; <span x-text="city">{$profile.city}</span></div>
</div>
</div>
<p x-text="bio">{$profile.bio}</p>
<div class="chips">
<template x-for="one in skills" :key="one"><span class="badge" x-text="one"></span></template>
</div>
<div class="profile-actions">
<button @click="liked = ! liked; likes += liked ? 1 : -1" :class="liked && 'is-liked'">
<span x-text="liked ? '♥' : '♡'"></span> <span x-text="likes">{$profile.likes}</span>
</button>
<form class="add-skill" @submit.prevent="skill.trim() && skills.push(skill.trim()); skill = ''">
<input class="input" x-model="skill" placeholder="Add a skill" aria-label="Add a skill">
</form>
</div>

<details class="panel state-dump">
<summary>The state as Alpine holds it</summary>
<pre x-text="JSON.stringify($data, null, 2)">
</details>
</div>

<div class="panel">
<div class="panel-title">Edit it live</div>
<p class="muted small">Two components, each with its own copy of the same JSON - change one, the other stays as PAD wrote it.</p>
<div x-data="{^profile}" class="stack-fields">
<label class="label">Name <input class="input" x-model="name"></label>
<label class="label">Role <input class="input" x-model="role"></label>
<p class="small">Preview: <strong x-text="name"></strong>, <span x-text="role"></span></p>
</div>
</div>

</div>


App: alpine - Page: examples/todos
PHP
examples/todos.php
HTML
examples/todos.pad
Result
<?php

// The list is the session's, started from _data/todos.json. Every change is a post - op=add
// with a text, toggle or remove with an id, clear for the done ones - and the answer is
// the list as it now stands ($padExpose): the component shows a change at once and then
// takes the server's list as the truth.

$todos = padSession ( 'alpineTodos', alpineData ( 'todos' ) );

if (
padRequestIs ( 'POST' ) ) {

$op = alpineText ( 'op' );
$id = (int) padRequest ( 'id', 0 );
$text = trim ( alpineText ( 'text' ) );

if (
$op == 'add' and $text !== '' )
$todos [] = [ 'id' => max ( array_column ( $todos, 'id' ) ?: [ 0 ] ) + 1, 'text' => mb_substr ( $text, 0, 80 ), 'done' => FALSE ];

foreach (
$todos as $at => $todo ) {
if (
$op == 'toggle' and $todo ['id'] == $id ) $todos [$at] ['done'] = ! $todo ['done'];
if (
$op == 'remove' and $todo ['id'] == $id ) unset ( $todos [$at] );
}

if (
$op == 'clear' )
$todos = array_filter ( $todos, fn ( $todo ) => ! $todo ['done'] );

$todos = array_values ( $todos );

padSessionPut ( 'alpineTodos', $todos );

}

$padExpose = [ 'todos' ];

?>
<div class="todos" x-data="todos({^todos})">

<form class="todo-add" @submit.prevent="add()">
<input class="input" x-model="text" placeholder="Something to do ..." aria-label="A new todo" maxlength="80">
<button>Add</button>
</form>

<div class="bar"><span :style="'width:' + progress() + '%'"></span></div>
<p class="muted small"><span x-text="done()"></span> of <span x-text="list.length"></span> done
<span x-show="syncing" x-cloak> &middot; saving ...</span></p>

<ul class="todo-list">
<template x-for="todo in list" :key="todo.id">
<li :class="todo.done && 'is-done'">
<label><input type="checkbox" :checked="todo.done" :disabled="!!todo.pending" @change="toggle(todo)"> <span x-text="todo.text"></span></label>
<button class="btn-icon btn-ghost" @click="remove(todo)" :disabled="!!todo.pending" :aria-label="'Remove ' + todo.text">×</button>
</li>
</template>
</ul>

<button class="btn-sm btn-ghost" @click="clear()" x-show="done() > 0" x-cloak>Clear the done ones</button>

<ul class="todo-list" x-show="false">
{todos}<li>{$text}</li>{/todos}
</ul>
</div>

<script src="{$padRoot}alpine/examples/todos.js"></script>


App: alpine - Page: guide
PHP
HTML
guide.pad
Result
{meta title='Guide - PAD + Alpine'}
<header class="example-head">
<h1>Alpine on a PAD page</h1>
<p class="lede">
Alpine reads attributes, PAD writes them. Both love braces - so give each its own, hand
Alpine its state as JSON, and let it talk back to the page that wrote it.
</p>
</header>

<h2>1. The braces</h2>
<p>PAD reads every <code>{</code> of a template as the start of a tag; Alpine writes objects
with them. Three ways that never collide:</p>
<div class="grid">
<div class="card">
<h3>State from PHP</h3>
<p>The ^ sigil writes a field as JSON, escaped for the attribute - <code>x-data="{^profile
}
"</code>.
No brace of Alpine's in the template, and a quote in a value stays text.</p>
</div>
<div class="card">
<h3>Components in a file</h3>
<p><code>Alpine.data('tabs', ...)</code> in a script of <code>www/</code>, called by name:
<code>x-data="tabs({^tabs})"</code> - the file never meets the PAD parser.</p>
</div>
<div class="card">
<h3>A space after the brace</h3>
<p><code>x-data="{ open: false }"</code> written as <code>{ open: false }</code> -
a brace with a space after it is no PAD tag. It works for a short object; prefer the other two.</p>
</div>
</div>

<h2>2. Load order</h2>
<p>The application's scripts first, Alpine last and deferred - a page's components register
themselves on <code>alpine:init</code> before Alpine walks the page:</p>
{highlight 'html'}
<script src="alpine-app.js"></script> <!-- pad.get, pad.post -->
<script defer src="https://unpkg.com/alpinejs@3/dist/cdn.min.js"></script>
...
<script src="examples/search.js"></script> <!-- Alpine.data('search', ...) -->
{/highlight}

<h2>3. Talking back</h2>
<p>A page answers JSON when asked - <code>?page&amp;padFormat=json</code> - with only what its PHP
names in <code>$padExpose</code>; the templates do not run for it. A post carries the session's
token in a header, read from <code><meta name="csrf-token" content="{csrf token}"></code>:</p>
{highlight 'js'}
const answer = await pad.get('examples/search', { q: this.q }); // the JSON of $padExpose
const saved = await pad.post('examples/todos', { op: 'add', text }); // X-CSRF-Token sent
{/highlight}

<h2>4. One set of rules</h2>
<p>Keep a form's rules in PHP: <code>padValidate ( $rules )</code> checks the post,
<code>padValidateClient ( $rules )</code> hands them to the component, {validator} brings the
checker, and <code>padValidate.field ( rules.email, value, form )</code> answers in the server's
words.</p>

<h2>5. Events from the server</h2>
<p><code>padSse()</code> streams JSON events; an <code>EventSource</code> opened in
<code>x-init</code> - in a component that closes it again in <code>destroy()</code> - turns them
into state.</p>

<div class="callout"><p>Values are text: whatever a visitor typed, the ^ sigil writes it as JSON escaped for the attribute,
and <code>x-text</code> puts it in as text. Avoid <code>x-html</code> with data that is not yours.</p></div>


App: alpine - Page: index
PHP
index.php
HTML
index.pad
Result
<?php

$groups
= alpineGroups ();

?>
<section class="hero">

<div>
<span class="eyebrow"><b>●</b> Behaviour in the markup</span>
<h1>PAD writes the page.<br><span class="grad">Alpine wakes it up.</span></h1>
<p class="lede">
Alpine adds behaviour to HTML with a few attributes - and PAD writes that HTML. Both
like braces, so the trick is to let each have its own: PAD hands Alpine its state as JSON
with the ^ sigil, and longer components live in a file of <code>www/</code>.
</p>
<div class="hero-actions">
<a class="btn" href="?examples/state">Start with state from PHP</a>
<a class="btn btn-ghost" href="?guide">Braces, data and requests</a>
</div>
</div>

<div class="hero-code">
{highlight 'pad'}
<div x-data="{^profile}"> <!-- $profile from the page's PHP -->
<h2 x-text="name"></h2>
<button @click="likes++">
♥ <span x-text="likes"></span>
</button>
</div>
{/highlight}
<p class="small muted">The template holds no brace of Alpine's: the state is the JSON of a PHP array.</p>
</div>

</section>

{groups}
<div class="group-head">
<h2>{$title}</h2>
<span class="badge">{$count} examples</span>
</div>
<p class="group-text">{$text}</p>
<div class="grid">
{examples}
<a class="card" href="?{$page}">
<span class="card-icon" aria-hidden="true">{$icon}</span>
<span class="card-go" aria-hidden="true">→</span>
<h3>{$title}</h3>
<p>{$lede}</p>
<div class="chips">{server}<code class="chip is-server">{$server}</code>{/server}{client}<code class="chip is-client">{$client}</code>{/client}</div>
</a>
{/examples}
</div>
{/groups}


App: apps - Page: browse
PHP
browse.php
HTML
browse.pad
Result
<?php

/**
* Redact sensitive values from source code before syntax highlighting.
* Replaces values of variables containing sensitive keywords with *REDACTED*
*/
function padColorsFileRedacted($file) {
$source = padFileGet($file);

// Pattern matches: $varName = 'value' or $varName = "value"
// For variables containing sensitive keywords (case-insensitive)
$sensitiveKeywords = [
'password', 'passwd', 'pwd', 'pass',
'secret',
'token',
'apikey', 'api_key',
'appkey', 'app_key',
'credential', 'cred',
'auth',
'private',
'salt',
'hash',
'cipher',
'encrypt',
];

$keywordPattern = implode('|', $sensitiveKeywords);

// Match: $varName = 'value' or $varName = "value" (with optional spaces)
// Captures: 1=variable with $, 2=equals and spaces, 3=quote char, 4=value, 5=closing quote
$pattern = '/(\$\w*(?:' . $keywordPattern . ')\w*)\s*(=\s*)([\'"])(.+?)\3/i';

$source = preg_replace_callback($pattern, function($matches) {
return
$matches[1] . $matches[2] . $matches[3] . '*REDACTED*' . $matches[3];
},
$source);

// Also handle array definitions like 'password'=> '*REDACTED*'
$arrayPattern = '/([\'"](?:' . $keywordPattern . ')[\'"])\s*(=>\s*)([\'"])(.+?)\3/i';

$source = preg_replace_callback($arrayPattern, function($matches) {
return
$matches[1] . $matches[2] . $matches[3] . '*REDACTED*' . $matches[3];
},
$source);

// Apply syntax highlighting to the redacted source. A highlighted PHP source keeps
// its braces, and spliced into a level they read as tags - so they travel escaped,
// the way the .pad walk escapes its own, and the exit puts them back.
if (substr($file, -4) == '.pad') {
return
padColorsString($source);
} else {
return
str_replace(['{', '}'], ['{', '}'], padColorsHighLight($source));
}
}

$title = 'Browse Application';

// Get the apps directory
$appsDir = dirname(APP) . '/';

// Get requested app, directory and file from query string
$app = padRequest ( 'app', '' );
$dir = padRequest ( 'dir', '' );
$file = padRequest ( 'file', '' );

// A list from the query string - app[]=x - names nothing: preg_match got the array and
// the page ended on a TypeError, a 500.
$app = is_string ( $app ) ? $app : '';
$dir = is_string ( $dir ) ? $dir : '';
$file = is_string ( $file ) ? $file : '';

$appPath = '';
$appDirs = [];
$appFiles = [];
$appExts = ['php', 'pad', 'json', 'xml', 'html', 'js', 'css'];
$source = '';
$currentFile = '';
$parentDir = '';

// Validate app name (security: prevent directory traversal). A nested application -
// regression/main, which the index links - is name segments joined by /, still with no
// dot and no empty segment, so it can not climb out.
if ($app && preg_match('/^[a-zA-Z0-9_-]+(\/[a-zA-Z0-9_-]+)*$/', $app)) {

$appPath = $appsDir . $app . '/';

if (
is_dir($appPath)) {

$title = "Browse: $app";

// Validate and set current directory
$currentDir = $appPath;
if (
$dir && preg_match('/^[a-zA-Z0-9_\-\/]+$/', $dir)) {
$testPath = $appPath . $dir . '/';
if (
is_dir($testPath) && strpos(realpath($testPath), realpath($appPath)) === 0) {
$currentDir = $testPath;
$title = "Browse: $app/$dir";

// Calculate parent directory
if (strpos($dir, '/') !== false) {
$parentDir = dirname($dir);
} else {
$parentDir = '';
}
}
} elseif (
$dir) {
// Invalid dir, reset
$dir = '';
}

// Scan current directory for files and subdirectories
$items = scandir($currentDir);

foreach (
$items as $item) {
// Skip hidden files and . / ..
if ($item[0] === '.') continue;

$itemPath = $currentDir . $item;
$relativePath = ($dir ? $dir . '/' : '') . $item;

if (
is_dir($itemPath)) {
$appDirs[] = [
'name' => $item,
'path' => $relativePath
];
} else {
$ext = pathinfo($item, PATHINFO_EXTENSION);
// Only show source files
if (in_array($ext, $appExts)) {
$appFiles[] = [
'path' => $relativePath,
'name' => $item,
'ext' => $ext
];
}
}
}

// Sort directories and files by name
$appDirs = padArrSortBy ( $appDirs, fn ( $one ) => strtolower ( $one ['name'] ) );
$appFiles = padArrSortBy ( $appFiles, fn ( $one ) => strtolower ( $one ['name'] ) );

// If a file is requested, load its source - a file the listing shows, and no other:
// a name with a part that starts with a dot is never listed, yet .env - where padEnv
// finds an application's passwords and keys - was shown in full to anyone who
// typed it, as was a file of any other kind.
if ($file && preg_match('/^[a-zA-Z0-9_\-\/\.]+$/', $file) && !preg_match('#(^|/)\.#', $file)
&&
in_array(pathinfo($file, PATHINFO_EXTENSION), $appExts)) {
$filePath = $appPath . $file;

if (
file_exists($filePath) && strpos(realpath($filePath), realpath($appPath)) === 0) {
$currentFile = $file;
$source = padColorsFileRedacted($filePath);
}
}
}
}

$dirCount = count($appDirs);
$fileCount = count($appFiles);
$hasParent = ($dir !== '');

?>
<style>
.browse-page { text-align: left; display: inline-block; }
.browse-container { display: flex; gap: 20px; }
.file-list { min-width: 150px; }
.file-list ul { list-style: none; padding: 0; margin: 0; }
.file-list li { padding: 2px 0; }
.file-list a { text-decoration: none; }
.file-list a:hover { text-decoration: underline; }
.file-list .current { font-weight: bold; }
.file-list .dir { font-weight: bold; }
.file-list .parent a { font-size: 1.4em; font-weight: bold; }
.source-view { overflow-x: auto; }
.source-view pre { background: #f8f8f8; padding: 10px; border: 1px solid #ddd; margin: 0; }
.back-link { margin-bottom: 10px; }
.path-info { color: #666; margin-bottom: 5px; }
</style>

<div class="browse-page">

{if $app ne ''}

<div class="browse-container">

<div class="file-list">
<ul>
{if $hasParent}
<li class="parent">
{if $parentDir ne ''}
<a href="?browse&app={$app}&dir={$parentDir}">..</a>
@else@
<a href="?browse&app={$app}">..</a>
{/if}
</li>
{/if}
{appDirs}
<li class="dir"><a href="?browse&app={$app}&dir={$path}">{$name}/</a></li>
{/appDirs}
{appFiles}
{if $currentFile eq $path}
<li class="current">{$name}</li>
{/if}
{if $currentFile ne $path}
<li><a href="?browse&app={$app}&dir={$dir}&file={$path}">{$name}</a></li>
{/if}
{/appFiles}
</ul>
</div>

{if $currentFile ne ''}
<div class="source-view">
<h3>{$currentFile}</h3>
{!source}
</div>
{/if}

</div>

{/if}

{if $app eq ''}
<p>No application specified. <a href="?index">Return to applications list</a>.</p>
{/if}
</div>


App: apps - Page: index
PHP
index.php
HTML
index.pad
Result
<?php

global $padRoot;

$title = 'PAD Applications';

// Get the apps directory (parent of current app)
$appsDir = dirname(APP) . '/';

$apps = [];

// Scan for application directories
$dirs = scandir($appsDir);

foreach (
$dirs as $dir) {

// Skip hidden files and current/parent dirs
if ($dir[0] === '.') continue;

// Skip _ prefixed directories except _common
if ($dir[0] === '_' && $dir !== '_common') continue;

$appPath = $appsDir . $dir . '/';

// Must be a directory
if (!is_dir($appPath)) continue;

// Skip self
if ($dir === 'apps') continue;

// Of the regression family only the main application is listed: the pages suites and
// the per-backend and per-action test applications are its instruments, and
// regression/main is where they are driven from. The regression/ directory itself is a
// namespace, not an app, so the listing descends into the one entry it shows.
if (str_starts_with($dir, 'regression') && $dir !== 'regression') continue;

if (
$dir === 'regression') {
$dir = 'regression/main';
$appPath = $appsDir . $dir . '/';
}

$app = [
'name' => $dir,
'path' => $appPath,
'link' => ($dir === '_common') ? '' : $padRoot . $dir . '/',
'description' => ''
];

// Try to read README.md and extract Introduction section
$readmePath = $appPath . 'README.md';

if (
file_exists($readmePath)) {
$content = file_get_contents($readmePath);

// Extract Introduction section content
// Pattern: ## Introduction followed by text until next ## or end
if (preg_match('/## Introduction\s*\n+(.+?)(?=\n## |\n#|\z)/s', $content, $matches)) {
$description = trim($matches[1]);
// Clean up: remove extra whitespace and limit length
$description = preg_replace('/\s+/', ' ', $description);
$app['description'] = $description;
}
}

$apps[] = $app;
}

// Sort: _common first, then alphabetically
usort($apps, function($a, $b) {
if (
$a['name'] === '_common') return -1;
if (
$b['name'] === '_common') return 1;
return
strcasecmp($a['name'], $b['name']);
});

$appCount = count($apps);

?>
<style>
.apps-list { border-collapse: collapse; margin: 0 auto; }
.apps-list th { text-align: left; padding: 8px 16px; border-bottom: 2px solid #999; }
.apps-list td { padding: 6px 16px; border-bottom: 1px solid #ccc; vertical-align: top; }
.apps-list a { text-decoration: none; }
.apps-list a:hover { text-decoration: underline; }
</style>

<p>{$appCount} applications</p>

<table class="apps-list">
<tr>
<th>Application</th>
<th>Description</th>
<th>Code</th>
</tr>
{apps}
<tr>
<td>
{if $link ne ''}<a href="{$link}">{$name}</a>{/if}
{if $link eq ''}{$name}{/if}
</td>
<td>
{if $description ne ''}{$description}{/if}
{if $description eq ''}<i>No description available</i>{/if}
</td>
<td><a href="{$padRoot}edit/?index&app={$name}">edit</a></td>
</tr>
{/apps}
</table>


App: charts - Page: area/cities
PHP
area/cities.php
HTML
area/cities.pad
Result
<?php

// The average temperature of every month in nine cities, in °C: the north and the south
// of Europe, a continental winter, the tropics that hardly change, and two cities south
// of the equator whose summer falls in January.

$normals = [
'Oslo' => [ -4, -4, 0, 5, 11, 15, 17, 16, 11, 6, 1, -3 ],
'Amsterdam' => [ 3, 4, 6, 9, 13, 16, 18, 18, 15, 11, 7, 4 ],
'Madrid' => [ 6, 8, 11, 13, 17, 23, 26, 25, 21, 15, 10, 7 ],
'Moscow' => [ -7, -6, -1, 6, 13, 17, 19, 17, 11, 5, -1, -5 ],
'Cairo' => [ 14, 15, 18, 22, 25, 27, 28, 28, 26, 24, 19, 15 ],
'Singapore' => [ 27, 27, 28, 28, 28, 28, 28, 28, 28, 28, 27, 27 ],
'Tokyo' => [ 5, 6, 9, 14, 19, 22, 26, 27, 24, 18, 13, 8 ],
'Sydney' => [ 23, 23, 22, 19, 16, 14, 13, 14, 16, 18, 20, 22 ],
'Cape Town' => [ 22, 22, 21, 18, 16, 14, 13, 14, 15, 17, 19, 21 ] ];

$months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];
$climate = [];

foreach (
$normals as $city => $temperatures )
foreach (
$temperatures as $m => $temperature )
$climate [] = [ 'city' => $city, 'month' => $months [$m], 'temperature' => $temperature ];

?>
{chart 'multiples',
data='climate',
by='city',
label='month',
value='temperature',
title='Average temperature per month in nine cities, in °C',
width=640,
height=420
}


App: charts - Page: area/devices
PHP
HTML
area/devices.pad
Result
{chart 'area',
label='year',
value='desktop, mobile, tablet',
percent,
title='Share of web visits by device',
width=640,
height=320
}

year,desktop,mobile,tablet
2012,1820,210,95
2013,1790,390,160
2014,1750,610,230
2015,1700,890,260
2016,1640,1210,250
2017,1580,1540,235
2018,1510,1860,215
2019,1450,2150,190
2020,1520,2380,180
2021,1430,2620,165
2022,1380,2810,150
2023,1340,2990,140
2024,1300,3150,130
{/chart}


App: charts - Page: area/energy
PHP
HTML
area/energy.pad
Result
{chart 'area',
label='year',
value='coal, gas, nuclear, wind, solar',
stacked,
title='Electricity generated by source, in TWh',
width=640,
height=340
}

year,coal,gas,nuclear,wind,solar
2010,310,250,140,40,12
2011,300,235,138,48,20
2012,315,205,136,55,28
2013,305,190,135,62,32
2014,285,180,134,70,36
2015,265,195,132,82,40
2016,225,230,130,90,44
2017,210,240,128,105,48
2018,195,235,127,115,53
2019,150,255,125,130,60
2020,125,245,118,145,70
2021,140,235,120,140,82
2022,150,205,110,155,100
2023,110,190,112,175,118
2024,90,180,112,190,135
{/chart}


App: charts - Page: area/music
PHP
area/music.php
HTML
area/music.pad
Result
<?php

// Two years of listening, in hours per month: pop steady with a summer swell, rock
// fading, hip-hop rising, electronic peaking each summer, jazz and classical in winter.

$listening = [];
$months = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ];

for (
$m = 0; $m < 24; $m++ ) {

$summer = cos ( ( $m % 12 - 6.5 ) / 12 * 2 * M_PI );
$winter = - $summer;

$listening [] = [
'month' => $months [$m % 12] . ' ' . ( 2025 + intdiv ( $m, 12 ) ),
'pop' => (int) round ( 42 + 10 * $summer + 4 * sin ( $m / 2 ) ),
'rock' => (int) round ( 38 - $m * 1.1 + 5 * sin ( $m / 3 + 1 ) ),
'hiphop' => (int) round ( 8 + $m * 1.6 + 3 * sin ( $m / 2.5 ) ),
'electronic' => (int) round ( 12 + 16 * max ( 0, $summer ) ** 2 ),
'jazz' => (int) round ( 9 + 7 * max ( 0, $winter ) ),
'classical' => (int) round ( 6 + 9 * max ( 0, $winter ) ** 3 + ( $m % 12 == 11 ? 8 : 0 ) ) ];

}

?>
{chart 'stream',
data='listening',
label='month',
value='pop, hiphop, rock, electronic, classical, jazz',
title='Hours listened per genre, month by month',
width=640,
height=340
}