Every WordPress site collects knowledge that lives nowhere: which plugin needs a licence key, why a template was overridden, the steps for the next deploy. This snippet adds a private Dev Notes page to the WordPress admin under Tools, only for administrators, so those notes stay with the site they belong to.
It is a small rich text editor: headings, bold, lists, tables and images from the media library, autosave three seconds after you stop typing, Ctrl+S or ⌘+S to save right away, and a read-only HTML view with syntax highlighting.
The snippet
<?php
/**
* Dev Notes: a private notes page for administrators under Tools → Dev Notes.
* Rich text with tables and images, autosave, Ctrl/Cmd+S, and a read-only HTML view.
*/
const DPC_DEV_NOTES_OPTION = 'dpc_dev_notes';
/** Saved notes. Falls back to the option of the older version of this snippet. */
function dpc_dev_notes_get(): string {
return (string) get_option( DPC_DEV_NOTES_OPTION, get_option( 'devnotes_data', '' ) );
}
add_action( 'admin_menu', 'dpc_dev_notes_menu' );
function dpc_dev_notes_menu(): void {
add_management_page(
__( 'Dev Notes', 'dpc' ),
__( 'Dev Notes', 'dpc' ),
'manage_options',
'dpc-dev-notes',
'dpc_dev_notes_page'
);
}
add_action( 'admin_enqueue_scripts', 'dpc_dev_notes_assets' );
function dpc_dev_notes_assets( $hook_suffix ): void {
if ( 'tools_page_dpc-dev-notes' !== $hook_suffix ) {
return;
}
wp_enqueue_media();
$code_editor = wp_enqueue_code_editor( array( 'type' => 'text/html' ) );
wp_register_script( 'dpc-dev-notes', false, array( 'media-editor' ), '1.0', true );
wp_enqueue_script( 'dpc-dev-notes' );
wp_add_inline_script(
'dpc-dev-notes',
'window.dpcDevNotes = ' . wp_json_encode(
array(
'ajaxUrl' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'dpc_dev_notes_save' ),
'codeEditor' => $code_editor,
'i18n' => array(
'saving' => __( 'Saving…', 'dpc' ),
'saved' => __( 'Saved', 'dpc' ),
'failed' => __( 'Save failed:', 'dpc' ),
'rows' => __( 'Number of rows:', 'dpc' ),
'cols' => __( 'Number of columns:', 'dpc' ),
'image' => __( 'Choose image', 'dpc' ),
'raw' => __( '</> Raw', 'dpc' ),
'visual' => __( 'Visual', 'dpc' ),
'leave' => __( 'You have unsaved notes.', 'dpc' ),
),
)
) . ';',
'before'
);
wp_add_inline_script( 'dpc-dev-notes', dpc_dev_notes_js() );
wp_register_style( 'dpc-dev-notes', false, array(), '1.0' );
wp_enqueue_style( 'dpc-dev-notes' );
wp_add_inline_style( 'dpc-dev-notes', dpc_dev_notes_css() );
}
function dpc_dev_notes_page(): void {
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( esc_html__( 'You do not have sufficient permissions to access this page.', 'dpc' ) );
}
$buttons = array(
array( 'bold', '', '<strong>B</strong>', __( 'Bold', 'dpc' ) ),
array( 'italic', '', '<em>I</em>', __( 'Italic', 'dpc' ) ),
array( 'underline', '', '<u>U</u>', __( 'Underline', 'dpc' ) ),
array( 'insertUnorderedList', '', '• List', __( 'Bullet list', 'dpc' ) ),
array( 'insertOrderedList', '', '1. List', __( 'Numbered list', 'dpc' ) ),
array( 'formatBlock', 'h2', 'H2', __( 'Heading 2', 'dpc' ) ),
array( 'formatBlock', 'h3', 'H3', __( 'Heading 3', 'dpc' ) ),
array( 'formatBlock', 'p', 'P', __( 'Paragraph', 'dpc' ) ),
);
?>
<div class="wrap dpc-dev-notes">
<div class="dpc-dev-notes__head">
<h1><?php esc_html_e( 'Dev Notes', 'dpc' ); ?></h1>
<span id="dpc-dev-notes-status" class="dpc-dev-notes__status" role="status"></span>
<button type="button" id="dpc-dev-notes-save" class="button button-primary" disabled><?php esc_html_e( 'Save notes', 'dpc' ); ?></button>
</div>
<div class="dpc-dev-notes__toolbar" role="toolbar">
<?php foreach ( $buttons as $b ) : ?>
<button type="button" class="button dpc-dev-notes__cmd" data-command="<?php echo esc_attr( $b[0] ); ?>" data-value="<?php echo esc_attr( $b[1] ); ?>" title="<?php echo esc_attr( $b[3] ); ?>"><?php echo wp_kses_post( $b[2] ); ?></button>
<?php endforeach; ?>
<button type="button" class="button dpc-dev-notes__cmd" id="dpc-dev-notes-table"><?php esc_html_e( 'Table', 'dpc' ); ?></button>
<button type="button" class="button dpc-dev-notes__cmd" id="dpc-dev-notes-image"><?php esc_html_e( 'Image', 'dpc' ); ?></button>
<button type="button" class="button" id="dpc-dev-notes-toggle"><?php esc_html_e( '</> Raw', 'dpc' ); ?></button>
</div>
<div id="dpc-dev-notes-editor" class="dpc-dev-notes__editor" contenteditable="true"><?php echo wp_kses_post( dpc_dev_notes_get() ); ?></div>
<div id="dpc-dev-notes-raw" class="dpc-dev-notes__raw" hidden>
<textarea id="dpc-dev-notes-code" readonly></textarea>
</div>
</div>
<?php
}
add_action( 'wp_ajax_dpc_dev_notes_save', 'dpc_dev_notes_ajax_save' );
function dpc_dev_notes_ajax_save(): void {
if ( ! check_ajax_referer( 'dpc_dev_notes_save', 'nonce', false ) || ! current_user_can( 'manage_options' ) ) {
wp_send_json_error( __( 'Permission denied.', 'dpc' ), 403 );
}
if ( ! isset( $_POST['content'] ) ) {
wp_send_json_error( __( 'No content.', 'dpc' ), 400 );
}
// Same HTML that is allowed in post content: no scripts, no event handlers.
$content = wp_kses_post( wp_unslash( (string) $_POST['content'] ) );
update_option( DPC_DEV_NOTES_OPTION, $content, false );
wp_send_json_success( array( 'content' => $content ) );
}
function dpc_dev_notes_css(): string {
return '.dpc-dev-notes__head{display:flex;align-items:center;gap:12px;margin-bottom:12px}'
. '.dpc-dev-notes__head h1{margin-right:auto}'
. '.dpc-dev-notes__status.is-error{color:#d63638}'
. '.dpc-dev-notes__toolbar{display:flex;flex-wrap:wrap;gap:4px;padding:8px;background:#f6f7f7;border:1px solid #c3c4c7;border-bottom:0}'
. '.dpc-dev-notes__toolbar .button.is-active{background:#2271b1;border-color:#2271b1;color:#fff}'
. '.dpc-dev-notes__editor,.dpc-dev-notes__raw .CodeMirror,#dpc-dev-notes-code{min-height:400px;border:1px solid #c3c4c7;background:#fff}'
. '.dpc-dev-notes__editor{padding:20px;font-size:14px;line-height:1.6;overflow:auto}'
. '.dpc-dev-notes__editor:focus{outline:2px solid #2271b1;outline-offset:-2px}'
. '.dpc-dev-notes__editor ul{list-style:disc}.dpc-dev-notes__editor ul,.dpc-dev-notes__editor ol{padding-left:2em}'
. '.dpc-dev-notes__editor table{border-collapse:collapse;width:100%;margin:1em 0}'
. '.dpc-dev-notes__editor td,.dpc-dev-notes__editor th{border:1px solid #c3c4c7;padding:6px 10px;text-align:left}'
. '.dpc-dev-notes__editor th{background:#f6f7f7}'
. '.dpc-dev-notes__editor img{max-width:100%;height:auto}'
. '#dpc-dev-notes-code{width:100%;font-family:Menlo,Consolas,monospace}';
}
function dpc_dev_notes_js(): string {
return <<<'JS'
(function () {
var cfg = window.dpcDevNotes;
var editor = document.getElementById('dpc-dev-notes-editor');
if (!cfg || !editor) {
return;
}
var raw = document.getElementById('dpc-dev-notes-raw');
var code = document.getElementById('dpc-dev-notes-code');
var toggle = document.getElementById('dpc-dev-notes-toggle');
var saveButton = document.getElementById('dpc-dev-notes-save');
var statusEl = document.getElementById('dpc-dev-notes-status');
var dirty = false;
var timer = 0;
var mirror = null;
function setStatus(text, isError) {
statusEl.textContent = text;
statusEl.classList.toggle('is-error', !!isError);
}
function changed() {
dirty = true;
saveButton.disabled = false;
clearTimeout(timer);
timer = setTimeout(save, 3000);
}
function save() {
clearTimeout(timer);
if (!dirty) {
return;
}
dirty = false;
saveButton.disabled = true;
setStatus(cfg.i18n.saving);
var body = new FormData();
body.append('action', 'dpc_dev_notes_save');
body.append('nonce', cfg.nonce);
body.append('content', editor.innerHTML);
fetch(cfg.ajaxUrl, { method: 'POST', body: body, credentials: 'same-origin' })
.then(function (response) { return response.json(); })
.then(function (result) {
if (!result.success) {
throw new Error(result.data || 'error');
}
setStatus(cfg.i18n.saved);
})
.catch(function (error) {
dirty = true;
saveButton.disabled = false;
setStatus(cfg.i18n.failed + ' ' + error.message, true);
});
}
function insert(html) {
editor.focus();
document.execCommand('insertHTML', false, html);
changed();
}
document.querySelectorAll('.dpc-dev-notes__cmd[data-command]').forEach(function (button) {
button.addEventListener('click', function () {
editor.focus();
document.execCommand(button.dataset.command, false, button.dataset.value || null);
changed();
});
});
document.getElementById('dpc-dev-notes-table').addEventListener('click', function () {
var rows = Math.min(50, Math.max(1, parseInt(window.prompt(cfg.i18n.rows, '3'), 10) || 0));
var cols = Math.min(20, Math.max(1, parseInt(window.prompt(cfg.i18n.cols, '3'), 10) || 0));
var html = '<table><tr>' + '<th> </th>'.repeat(cols) + '</tr>';
for (var r = 1; r < rows; r++) {
html += '<tr>' + '<td> </td>'.repeat(cols) + '</tr>';
}
insert(html + '</table><p><br></p>');
});
document.getElementById('dpc-dev-notes-image').addEventListener('click', function () {
if (!window.wp || !wp.media) {
return;
}
var frame = wp.media({ title: cfg.i18n.image, multiple: false, library: { type: 'image' } });
frame.on('select', function () {
var file = frame.state().get('selection').first().toJSON();
var img = document.createElement('img');
img.src = file.url;
img.alt = file.alt || '';
insert(img.outerHTML + '<p><br></p>');
});
frame.open();
});
toggle.addEventListener('click', function () {
var showRaw = raw.hidden;
raw.hidden = !showRaw;
editor.hidden = showRaw;
toggle.classList.toggle('is-active', showRaw);
toggle.textContent = showRaw ? cfg.i18n.visual : cfg.i18n.raw;
document.querySelectorAll('.dpc-dev-notes__cmd').forEach(function (button) {
button.disabled = showRaw;
});
if (!showRaw) {
editor.focus();
return;
}
var html = editor.innerHTML.replace(/>\s*</g, '>\n<');
if (mirror) {
mirror.codemirror.setValue(html);
mirror.codemirror.refresh();
} else if (cfg.codeEditor && wp.codeEditor) {
code.value = html;
var settings = Object.assign({}, cfg.codeEditor);
settings.codemirror = Object.assign({}, cfg.codeEditor.codemirror, { readOnly: true, lineWrapping: true });
mirror = wp.codeEditor.initialize(code, settings);
} else {
code.value = html;
}
});
editor.addEventListener('input', changed);
editor.addEventListener('keydown', function (event) {
if (event.key === 'Tab') {
event.preventDefault();
document.execCommand('insertText', false, ' ');
}
});
saveButton.addEventListener('click', save);
document.addEventListener('keydown', function (event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
event.preventDefault();
save();
}
});
window.addEventListener('beforeunload', function (event) {
if (dirty) {
event.preventDefault();
event.returnValue = cfg.i18n.leave;
}
});
})();
JS;
}
How it works
add_management_page()adds Tools → Dev Notes with themanage_optionscapability, so only administrators see it. The page checks the capability again before it renders.- The CSS and JavaScript are attached to their own handles with
wp_add_inline_style()andwp_add_inline_script(), and only on this one admin screen.wp_enqueue_media()loads the media library, andwp_enqueue_code_editor()WordPress’s CodeMirror for the HTML view. - Saving goes through
admin-ajax.php. The handler checks a nonce and the capability, cleans the HTML withwp_kses_post()and stores it in thedpc_dev_notesoption with autoload off, so it is not loaded on every request. - The page prints the notes through
wp_kses_post()as well. Pasted<script>tags oronerrorattributes never reach the browser. - If you used the older version, its notes in
devnotes_datashow up until the first save.
The older version printed the saved HTML unfiltered, attached its settings to the jquery handle, kept the whole page in one PHP template with inline <script> and <style>, and had a second save handler for a form that did not exist.
Add it with Scripts Organizer
In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty.
Saving runs through admin-ajax.php, and Everywhere makes sure the save handler is registered for that request. The CSS and JavaScript live inside the PHP and are printed only on the Dev Notes screen.
The same code also works unchanged in a small plugin or an mu-plugin.
Customise it
- Other location: replace
add_management_page()withadd_menu_page()to give Dev Notes its own top-level menu item. - Let editors read and write notes: change
manage_optionstoedit_others_postsin the three places it appears. - Show the notes on the Dashboard as well: add a widget with
wp_add_dashboard_widget()that printswp_kses_post( dpc_dev_notes_get() ).
Gotchas
- The formatting buttons use
document.execCommand(). Browsers mark it as deprecated but still support it, and there is no replacement for contenteditable editing yet. - The notes are stored in the database. They are part of every database export, so do not keep passwords or API keys there.
- On multisite each site has its own notes.
In the Scripts Organizer library Import it with one click from Scripts Organizer.