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> · <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> · 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&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}
|
|
|