Skip to content

Pramnos Framework - Document & Output System Guide

The Pramnos Framework includes a sophisticated document and output system that handles multiple output formats, theming, asset management, and content rendering. This guide covers the complete document system from basic usage to advanced customization.

Table of Contents

  1. Architecture Overview
  2. Document Types and Formats
  3. Basic Document Usage
  4. Asset Management
  5. Theme Integration
  6. Multi-Format Output
  7. Meta Tags and SEO
  8. Content Parsing and Processing
  9. Advanced Features
  10. Best Practices

Architecture Overview

The document system consists of several key components:

src/Pramnos/Document/
├── Document.php              # Base document class
└── DocumentTypes/           # Format-specific implementations
    ├── Html.php             # HTML document type
    ├── Amp.php              # AMP (Accelerated Mobile Pages)
    ├── Json.php             # JSON body with an application/json header
    ├── PrintDocument.php    # Printable HTML (print dialog / save as PDF)
    ├── Png.php              # PNG image output
    ├── Raw.php              # The body as-is, no theme, no headers
    ├── Rss.php              # RSS feed generation
    └── Rss/
        └── Item.php         # RSS item class

Key Features

  • Multiple Output Formats: HTML, AMP, print, RSS, PNG, JSON, raw
  • Asset Management: JavaScript and CSS dependency management
  • Theme Integration: Seamless theming and template system
  • SEO Optimization: Meta tags, Open Graph, and structured data
  • Content Processing: Parsing, filtering, and transformation
  • Responsive Design: Mobile-first and AMP support

Document Types and Formats

Available Document Types

// Get document instance for different formats
$htmlDoc = \Pramnos\Framework\Factory::getDocument('html');
$printDoc = \Pramnos\Framework\Factory::getDocument('print');
$rssDoc = \Pramnos\Framework\Factory::getDocument('rss');
$ampDoc = \Pramnos\Framework\Factory::getDocument('amp');
$jsonDoc = \Pramnos\Framework\Factory::getDocument('json');
$rawDoc = \Pramnos\Framework\Factory::getDocument('raw');

The known types are html, amp, json, rss, print, pdf (an alias for print), raw and png (Document::KNOWN_TYPES). getDocument('<type>') also makes that type the default for later getDocument() calls with no argument. Calling it with no argument picks the type from the ?format= query parameter when it names a known type, and the current default otherwise.

Document::setType() is static: it sets that default and does not change the document you call it on. To switch, ask for the other document.

HTML Document Type

$doc = \Pramnos\Framework\Factory::getDocument('html');

// Set basic properties
$doc->title = 'Page Title';
$doc->description = 'Page description for SEO';

// Add content
$doc->addContent('<h1>Hello World</h1>');

// Add HTML-specific attributes
$doc->extraHtmlTag = 'data-theme="dark"';
$doc->extraBodyTag = 'onload="initPage()"';

// Render the document
echo $doc->render();

Printable Document Type

Produces a normal HTML page carrying a print stylesheet and a call to window.print(). The browser's own print dialog handles the rest — including Save as PDF, which every current browser offers.

It is an HTML document in every respect: theme, meta tags, addCss(), enqueueStyle(), enqueueScript() all work as they do on a html document.

$doc = \Pramnos\Framework\Factory::getDocument('print');

$doc->title       = 'Invoice 2026-0042';
$doc->paperSize   = 'A4';          // or 'Letter', 'A5', '210mm 297mm'
$doc->orientation = 'portrait';    // or 'landscape'
$doc->margin      = '15mm';

$doc->addCss('/css/invoice.css');                    // the real layout
$doc->addPrintCss('.totals { break-inside: avoid; }'); // page-specific rules
$doc->noPrint('.site-nav');                          // hide from the printout

$doc->addContent('
    <h1>Invoice 2026-0042</h1>
    <table>
        <tr><th>Item</th><th>Amount</th></tr>
        <tr><td>Services</td><td>1,200.00</td></tr>
    </table>
');

echo $doc->render();

Properties

Property Default What it does
autoPrint true Open the print dialog once the page has loaded
closeAfterPrint false Close the window on afterprint — only works for a window the script opened
paperSize 'A4' @page size
orientation 'portrait' Appended to the size when it is portrait/landscape
margin '12mm' @page margin
baseStyles true Emit the built-in print stylesheet
printStyles '' Extra CSS, appended after it — see addPrintCss()
hideOnPrint [] Selectors hidden when printing — see noPrint()

Anything with class .no-print is hidden already, and .page-break forces a page break before the element.

Set autoPrint = false for a page the reader is meant to check before printing.

pdf is an alias. getDocument('pdf') returns this printable document, so a ?format=pdf link produces a page the browser can save as PDF. The framework has no server-side PDF renderer. Use 'print' in new code.

RSS Feed Type

$doc = \Pramnos\Framework\Factory::getDocument('rss');

// Set feed properties
$doc->title = 'My Blog RSS Feed';
$doc->description = 'Latest blog posts';
$doc->link = 'https://example.com';

// Create RSS items
$item = new \Pramnos\Document\DocumentTypes\Rss\Item();
$item->title = 'Blog Post Title';
$item->description = 'Post excerpt or summary';
$item->link = 'https://example.com/blog/post-slug';
$item->pubDate = date('r'); // RFC 2822 date format

// Add item to feed
$doc->addItem($item);

// Output RSS XML
echo $doc->render();

AMP Document Type

$doc = \Pramnos\Framework\Factory::getDocument('amp');

// Set AMP properties
$doc->title = 'AMP Page Title';
$doc->canonical = 'https://example.com/original-page'; // Original page URL

// AMP-specific meta tags
$doc->og_image = 'https://example.com/image.jpg';

// Add AMP-compatible content
$doc->addContent('
    <h1>AMP Page</h1>
    <amp-img src="/image.jpg" width="600" height="400" layout="responsive"></amp-img>
');

echo $doc->render();

Basic Document Usage

Setting Document Properties

$doc = \Pramnos\Framework\Factory::getDocument();

// Basic properties
$doc->title = 'Page Title';
$doc->description = 'Page description for search engines';
// The <html lang> and the charset come from the active language
// (its LangShort and CHARSET strings), not from a document setter.

// URL and canonical
$doc->url = 'https://example.com/current-page';

// Content modification date
$doc->mdate = date('c'); // ISO 8601 format

Adding Content

// Add content to document
$doc->addContent('<h1>Main Content</h1>');
$doc->addContent('<p>Additional content paragraph.</p>');

// Set content directly
$doc->setContent('<div>Complete page content</div>');

// Get current content
$currentContent = $doc->getContent();

Document Rendering

// Basic rendering
$output = $doc->render();

// Render and output
echo $doc->render();

// Check document type
if ($doc->getType() === 'html') {
    // HTML-specific processing
}

Asset Management

JavaScript Management

$doc = \Pramnos\Framework\Factory::getDocument();

// Register scripts with dependencies
$doc->registerScript(
    'my-script',                                    // Handle
    '/assets/js/my-script.js',                     // Source URL
    ['jquery', 'jquery-ui'],                       // Dependencies
    '1.0.0',                                       // Version
    true                                           // Load in footer
);

// Enqueue scripts for loading
$doc->enqueueScript('my-script');
$doc->enqueueScript('jquery'); // Dependencies loaded automatically

// 'mtime' as the version: ?v= the file's modification time, so a deploy is a new address
$doc->registerScript('app', sURL . 'assets/js/app.js', [], 'mtime', true);
$doc->registerStyle('app', sURL . 'assets/css/app.css', [], 'mtime');

// Add an inline script (a bare <script> appended to the page foot)
$doc->addInlineScript('
    window.addEventListener("load", function() {
        console.log("Page loaded");
    });
');

The handles a document registers for you

A document constructor pre-registers a set of handles, so a template can enqueue them with no source:

$doc->enqueueScript('slimbox2');   // no src — resolved from the registration

An unregistered handle with no source throws — and it throws when the queue is processed, not when enqueueScript() is called, so a missing registration reaches production as a broken page rather than a broken template. isScriptRegistered() and isStyleRegistered() answer before you commit to it.

Registered by default: jquery, jquery-ui, the datatables family, jquery-tmpl, iframe-transport, the jquery-fileupload family, bootstrap-datepicker, jquery-inputmask (plus -extensions and -date, which the 3.3.4 bundle contains and which resolve to it), slimbox2, thickbox, spectrum, mediamanager, and the Spry* family.

The last group is registered for compatibility, not on merit: Adobe Spry has been unmaintained since 2012 and the lightboxes are jQuery-era. They are here because templates that still enqueue them by handle should not fatal in an admin panel. There is no jquery-inputmask-jui.

The framework does not ship the files for any local registration, exactly as it does not ship jquery-ui.min.js. A registration is a handle-to-URL mapping; the application provides the file.

Three defaults come from a CDN, and that is configurable

jquery, bootstrap-datepicker and jquery-inputmask are registered against cdnjs.cloudflare.com. Everything else is local, under sURL.

Two consequences worth knowing before you leave the default in place:

  • GDPR, for a site with EU visitors: an IP address reaches Cloudflare before any consent is collected;
  • CSP: a policy written for a self-hosted application does not list that origin, so the scripts are blocked rather than merely remote.

The default stays the CDN, because changing it would break applications that no longer vendor these files. To serve them yourself:

// application settings
'documentAssetSource' => 'local',

which registers all three at these paths:

Handle local path
jquery media/js/jquery/jquery.min.js
bootstrap-datepicker plugins/datepicker/bootstrap-datepicker.js, and its style plugins/datepicker/bootstrap-datepicker3.standalone.min.css
jquery-inputmask plugins/input-mask/jquery.inputmask.js

Check the files exist before switching. local does not verify anything — it changes a URL, and a URL with nothing behind it is a 404 the browser reports and PHP does not:

ls media/js/jquery/jquery.min.js \
   plugins/datepicker/bootstrap-datepicker.js \
   plugins/datepicker/bootstrap-datepicker3.standalone.min.css \
   plugins/input-mask/jquery.inputmask.js

So it takes a list as well as 'local', for the common case of having vendored some and not others:

'documentAssetSource' => ['jquery'],   // jquery local; the other two stay on the CDN

All-or-nothing would not serve the common case: an application with jquery.min.js vendored and no plugins/ directory would 404 two of the three, and its only choices would be a GDPR problem or two broken scripts.

A comma-separated string and a JSON array are accepted too, because settings round-trip a list differently depending on how it was stored.

All four forms are equivalent — an array, an stdClass, a JSON string, a comma-separated string — and which one comes back depends on how the setting was written and read, not on what you meant:

'documentAssetSource' => ['jquery']                      // array
'documentAssetSource' => '["jquery"]'                    // JSON, as some stores return it
'documentAssetSource' => 'jquery, bootstrap-datepicker'  // typed into a form

Every form goes through the same reader (localHandles()), including the "is anything on the CDN" check, so a list stored as JSON or typed into a form behaves exactly like the array.

The default is the CDN. Not configuring this sends every visitor's browser to a third-party host for three scripts, which is a privacy decision an installation inherits without making it.

CSS Management

// Register stylesheets
$doc->registerStyle(
    'my-styles',                                   // Handle
    '/assets/css/my-styles.css',                  // Source URL
    ['datatables'],                                // Dependencies (registered handles)
    '1.0.0',                                      // Version
    'all'                                         // Media type
);

// Enqueue stylesheets
$doc->enqueueStyle('my-styles');

// Add inline styles (no dedicated method: put a <style> block in the head)
$doc->addHeadContent('<style>
    .custom-class {
        background-color: #f0f0f0;
        padding: 20px;
    }
</style>');

Default Framework Assets

// Framework provides pre-registered assets
$doc->enqueueScript('jquery');          // jQuery 2.2.4
$doc->enqueueScript('jquery-ui');       // jQuery UI
$doc->enqueueScript('datatables');      // DataTables
$doc->enqueueScript('bootstrap-datepicker');
$doc->enqueueScript('jquery-inputmask');

// Stylesheets (there is no pre-registered 'bootstrap' handle — register your own)
$doc->enqueueStyle('jquery-ui');
$doc->enqueueStyle('datatables');

What the framework does not inject, and why

Html::render() emits one thing you did not ask for: a two-line inline script that replaces class="no-js" on <html> with js, so a stylesheet can style the no-JavaScript case. It is inline because a round trip to decide whether JavaScript exists would arrive after the page had already been painted.

It does not inject modernizr, and that is deliberate, for two reasons:

  • The framework does not ship that file. A project scaffolded by init has no media/js/modernizr.min.js, so an unconditional injection would be a 404 on every page of every new project.
  • A page's assets are the application's decision. That is what the registry above is for — a default that cannot be seen in the calling code is a default nobody knows to turn off.

If your CSS depends on modernizr's feature classes (touch/no-touch and the rest — the narrow no-js/js flip is already covered), add it explicitly:

// Anywhere the theme's header is composed.
$document->addHeadContent(
    '<script async src="' . sURL . 'media/js/modernizr.min.js"></script>'
);

The same applies to a reset stylesheet: enqueue it, and it is visible in the calling code.

Fetching the next page before it is clicked: speculation rules

The browser can fetch, or prerender, the page a link leads to before it is clicked, by default on hover. The HTML document writes <script type="speculationrules"> in the head when app.php asks:

'speculation' => [
    'prefetch'  => ['eagerness' => 'moderate', 'include' => ['/*'], 'exclude' => ['/account/*']],
    // 'prerender' => [...],     the same shape
],
  • What is always left out: a sign-out, a ?format= export, a rel=nofollow link, one marked .no-prefetch, and the page cache's bypassPaths that are globs. A regular expression is not a URL pattern, so those are not carried over.
  • A link that changes something must be excluded, or made a POST. "Add to favourites" as a GET link is the usual case: a prefetch is a request.
  • The CSP holds a hash of the configured rules, because they are the same on every page. They need no nonce, and the page stays cacheable.
  • A page's own rule, $doc->addSpeculationRule('prefetch', ['source' => 'list', 'urls' => ['/next']]), is added beside them and carries the nonce, like any inline script.

Asset Dependencies

// Complex dependency example
$doc->registerScript('app-core', '/js/core.js', ['jquery'], '1.0');
$doc->registerScript('app-utils', '/js/utils.js', ['app-core'], '1.0');
$doc->registerScript('app-main', '/js/main.js', ['app-utils', 'datatables'], '1.0');

// Only need to enqueue the main script - dependencies load automatically
$doc->enqueueScript('app-main');

Theme Integration

Using Themes

// Get document instance
$doc = \Pramnos\Framework\Factory::getDocument();

// Theme is automatically loaded based on application settings
// Access theme object
$theme = $doc->themeObject;

if ($theme) {
    // Check theme capabilities
    if ($theme->allowsViewOverrides()) {
        // Theme supports view overrides
    }

    // Get theme settings
    $customSetting = $theme->getSetting('custom_option');

    // Set content type for theme
    $theme->setContentType('single'); // page, single, archive, etc.
}

Theme Content Integration

// In controllers, content is automatically integrated with theme
class PageController extends \Pramnos\Application\Controller
{
    public function display()
    {
        $doc = \Pramnos\Framework\Factory::getDocument();
        $doc->title = 'Page Title';

        $view = $this->getView('Page');
        return $view->display('content');

        // Theme automatically wraps view content with header/footer
    }
}

Disabling Theme

// Disable theme for specific output (like AJAX responses)
$doc = \Pramnos\Framework\Factory::getDocument();
$doc->usetheme = false;

// Or switch to a document type that doesn't use themes
$doc = \Pramnos\Framework\Factory::getDocument('json');   // or 'raw'

Multi-Format Output

Format Detection and Switching

// Automatic format detection: getDocument() with no argument reads ?format=
// when it names a known type. The path's extension plays no part.
// /page              -> the current default (HTML)
// /page?format=print -> printable page (save as PDF from the browser)
// /page?format=rss   -> RSS output
// /page?format=json  -> JSON output

// Manual format switching
$format = \Pramnos\Http\Request::staticGet('format', 'html', 'get');

switch ($format) {
    case 'print':
        $doc = \Pramnos\Framework\Factory::getDocument('print');
        break;
    case 'rss':
        $doc = \Pramnos\Framework\Factory::getDocument('rss');
        break;
    case 'json':
        $doc = \Pramnos\Framework\Factory::getDocument('json');
        break;
    default:
        $doc = \Pramnos\Framework\Factory::getDocument('html');
}

A JSON endpoint inside an MVC controller

An action that answers JSON — an AJAX widget's numbers, a modal's contents — must switch the document, not only the Content-Type header:

public function activeusers(): void
{
    \Pramnos\Framework\Factory::getDocument('json');   // ← the load-bearing line
    header('Content-Type: application/json');
    echo json_encode($counts);
}

A header alone does not stop the page. The action echoes and returns, and the request goes on to render the theme, so the response is the JSON followed by a complete web page. fetch(url).then(r => r.json()) throws on that, and what a person sees is a widget whose numbers never appear — with a 200 status and a response that begins with exactly the right JSON.

The alternative, for an action whose signature allows a return value, is return \Pramnos\Http\Response::json($data); — which carries the status and the headers with it. Prefer it in new code; the getDocument('json') form is what a void action needs.

JSON Output

// For API responses: the json type renders no theme and sends
// Content-type: application/json with the language's charset itself
$doc = \Pramnos\Framework\Factory::getDocument('json');

// Add JSON data
$data = [
    'status' => 'success',
    'data' => $results,
    'message' => 'Operation completed'
];

$doc->setContent(json_encode($data, JSON_PRETTY_PRINT));

echo $doc->render();

XML Output

There is no xml document type. Use raw, which renders the body as-is with no theme and sends no Content-Type of its own, and set the header yourself:

$doc = \Pramnos\Framework\Factory::getDocument('raw');

$xml = '<?xml version="1.0" encoding="UTF-8"?>
<response>
    <status>success</status>
    <data>
        <item id="1">Sample data</item>
    </data>
</response>';

$doc->setContent($xml);

if (!headers_sent()) {
    header('Content-Type: text/xml');
}

echo $doc->render();

Meta Tags and SEO

There is no addMetaName(), addMeta() or addScriptDeclaration()

None of the three exists on any document type. Code that calls them fails with Call to undefined method. The API below is the real one.

Titles and descriptions

Both are plain properties:

$doc = \Pramnos\Framework\Factory::getDocument();

$doc->title       = 'Page Title - Site Name';
$doc->description = 'Compelling page description under 160 characters';

Meta tags

One method covers both spellings. $isName = true emits <meta name="…">, the default emits <meta property="…">:

$doc->addMetaTag('keywords', 'keyword1, keyword2', true);   // <meta name="keywords" …>
$doc->addMetaTag('robots',   'index, follow',      true);
$doc->addMetaTag('viewport', 'width=device-width, initial-scale=1.0', true);

$doc->addMetaTag('og:article:author', 'Author Name');       // <meta property="og:…" …>

removeMetaTag($tag) removes one again.

Open Graph

The six most-used slots are properties rather than meta tags, because the document types render them in a fixed order:

$doc->og_title       = 'Social Media Title';
$doc->og_description = 'Description for social media';
$doc->og_image       = 'https://example.com/image.jpg';
$doc->og_url         = 'https://example.com/current-page';
$doc->og_type        = 'article';        // article, website, video, …
$doc->og_site_name   = 'Site Name';

Anything beyond those six goes through addMetaTag() with the og: prefix, as above.

Twitter cards

Twitter reads name, not property, so pass true:

$doc->addMetaTag('twitter:card',        'summary_large_image', true);
$doc->addMetaTag('twitter:site',        '@yourusername',       true);
$doc->addMetaTag('twitter:title',       'Twitter Title',       true);
$doc->addMetaTag('twitter:description', 'Twitter description', true);
$doc->addMetaTag('twitter:image',       'https://example.com/twitter-image.jpg', true);

Schema.org structured data

$doc->addStructuredData([
    '@context' => 'https://schema.org',
    '@type'    => 'Article',
    'headline' => $article['title'],
    'author'   => ['@type' => 'Person', 'name' => $article['author']],
]);

Repeatable — a page carrying an Article and a BreadcrumbList calls it twice and gets two blocks. They are deliberately not merged: two @types in one object is something no validator accepts.

Absent is not empty. Omit a key you have no value for rather than emitting "genre": "". An empty string is a claim that the field is blank, which is a different statement from not making the claim, and consumers treat it as one. The framework cannot do this for you — it cannot tell a deliberate empty string from a failed lookup.

The encoding is handled, and the flags are not a preference:

Flag What it prevents
JSON_HEX_TAG The one injection this format has. A </script> inside any value would end the block early and everything after it would be parsed as markup
JSON_HEX_AMP the same, one step out, for consumers that re-parse the block from an HTML string
JSON_UNESCAPED_SLASHES every URL becoming https:\/\/… — valid, and unreadable in view-source, which is the only place anybody checks
JSON_UNESCAPED_UNICODE non-Latin text becoming \uXXXX, same cost, no benefit

If the data cannot be encoded at all — a resource handle, invalid UTF-8 — no block is emitted. A page without structured data is a smaller problem than a page with a broken script tag in its head.

Pramnos\Html\Seo::jsonLd($data) returns the same string for a page assembled without a Document — a layout template, say. One implementation, two ways in.

Do not use addInlineScript() for this. It hardcodes a bare <script> with no type and appends to the foot, not the head — so the browser would run your JSON-LD as JavaScript.

$doc->setCanonical(URL . 'station/' . $station['slug']);

Pass an absolute URL. A relative canonical is legal and is resolved against the current address, which makes it a no-op that looks like a decision — the case it exists to fix, two addresses serving one page, is exactly the case where "the current address" is the wrong answer.

The AMP document type computes one when none is set and has always emitted it; setting one overrides that. It emits exactly one either way: two <link rel="canonical"> elements on a page is undefined behaviour to a crawler, which is worse than none.

Pramnos\Html\Seo::canonicalLink($url) returns the element for a page built without a Document.

Escaping: what the document escapes for you, and what it does not

Every value the document types put in the <head> — title, description, all six og_* slots, both meta-tag names and their values, and the AMP canonical — is escaped when the page renders. You pass raw text; the renderer makes it safe.

That matters because of what those values usually are: a record's name, operator-written copy, a title from the database — the strings least likely to be trusted, in the one part of the page nobody reads. One double quote in a record's name would otherwise end the attribute, and everything after it would be parsed as markup.

Three things follow:

  • Do not escape them yourself. It is harmless if you do — escaping uses double_encode: false, so &amp; stays &amp; rather than becoming &amp;amp; — but it is unnecessary.
  • addHeadContent(), addHeadTagContent() and extraHtmlTag / extraBodyTag are NOT escaped, by design: they exist to carry markup. Anything you interpolate into them is yours to escape with htmlspecialchars().
  • Body classes are escaped too. The guarantee is not limited to <head>: addBodyClass() values go through the same escaping, because an application reasonably feeds it a slug, a content type or a user's chosen theme name, and a " in any of those closes the class attribute.
  • A value that is null, an array or an object renders as an empty string rather than raising. A blank title is bad; a fatal error while rendering the <head> is worse.

The document does not decide the status code

A renderer sets the content type. The status belongs to whatever decided what kind of response this is — the controller, a middleware, the router's error handler:

http_response_code(404);
$doc = \Pramnos\Framework\Factory::getDocument('json');
$doc->addContent(json_encode(['error' => 'not found']));
echo $doc->render();          // still a 404

If you are writing a document type or a middleware, use http_response_code() and never header('HTTP/...'). A hand-written status line does two things you do not want: it stamps its own status over the one already set, so a JSON error is served as 200 OK and a client checking response.ok reads every failure as a success carrying odd data; and it pins the status, because PHP ignores every later http_response_code() once a status line has been written by hand. The literal HTTP/1.1 is also wrong on an HTTP/2 connection — http_response_code() lets PHP write the right one.

Content Parsing and Processing

Content Filters and Parsing

$doc = \Pramnos\Framework\Factory::getDocument();

// Parse content through addon filters
$content = '<p>Raw content with [shortcode]</p>';
$parsedContent = $doc->parse($content, 'html', 'html');

// Content is automatically filtered through registered addons

Custom Content Processing

// In an addon class
class ContentProcessorAddon extends \Pramnos\Addon\Addon
{
    public function onParse($text, $texttype, $doctype)
    {
        // Process shortcodes
        $text = preg_replace_callback('/\[gallery\s+([^\]]*)\]/', function($matches) {
            return $this->renderGallery($matches[1]);
        }, $text);

        // Process custom markup
        $text = str_replace('[contact-form]', $this->renderContactForm(), $text);

        return $text;
    }

    private function renderGallery($attributes)
    {
        // Gallery rendering logic
        return '<div class="gallery">Gallery content</div>';
    }
}

Body Classes and Styling

$doc->addBodyClass('page-home');
$doc->addBodyClass('user-logged-in');
$doc->addBodyClass('theme-' . $user->theme);   // escaped for you — see below

The list is emitted space-separated on the <body> tag by Html::render() and Amp::render(), and each value is escaped. With no classes and no extraBodyTag, the tag is <body> — nothing to opt out of and no stray attribute.

For raw attributes on the tag itself — event handlers, data- attributes, anything that is markup rather than a class name — use $doc->extraBodyTag, which is deliberately not escaped and is therefore yours to make safe.

Advanced Features

Custom Document Types

// Create custom document type
namespace MyApp\Document;

class CustomDocument extends \Pramnos\Document\Document
{
    public function render()
    {
        // Set custom headers
        if (!headers_sent()) {
            header('Content-Type: application/custom+xml');
            header('X-Custom-Header: MyValue');
        }

        // Custom rendering logic
        $content = '<?xml version="1.0"?>';
        $content .= '<custom>';
        $content .= $this->getContent();
        $content .= '</custom>';

        return $content;
    }
}

// Register and use custom document type
$doc = new MyApp\Document\CustomDocument();

Document Hooks and Events

A document runs one addon hook while it renders: onParse($text, $texttype, $doctype) on every active content addon, over the theme's head and foot (see Custom Content Processing), plus the send_headers action. The body content is not passed through it, and there are no before/after-render hooks. To post-process the final markup, work on the string render() returns:

$html = $doc->render();
$html = str_replace('</body>', $analyticsCode . '</body>', $html);
echo $html;

AMP Optimization

// AMP-specific optimizations
$doc = \Pramnos\Framework\Factory::getDocument('amp');

// AMP requires specific image handling
$content = '<amp-img src="/image.jpg" width="600" height="400" layout="responsive" alt="Description"></amp-img>';

// AMP analytics: the component script in the head, the element in the body
$doc->addHeadContent(
    '<script async custom-element="amp-analytics" '
    . 'src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>'
);
$content .= '
<amp-analytics type="googleanalytics">
<script type="application/json">
{
  "vars": {
    "account": "UA-XXXXX-Y"
  },
  "triggers": {
    "trackPageview": {
      "on": "visible",
      "request": "pageview"
    }
  }
}
</script>
</amp-analytics>
';

$doc->addContent($content);

Best Practices

Document Structure

// Follow consistent document structure pattern
class MyController extends \Pramnos\Application\Controller
{
    public function display()
    {
        // 1. Get document instance
        $doc = \Pramnos\Framework\Factory::getDocument();

        // 2. Set document properties
        $doc->title = 'Page Title';
        $doc->description = 'Page description';

        // 3. Add meta tags and assets
        $doc->addMetaTag('keywords', 'relevant, keywords', true);
        $doc->enqueueScript('page-specific-js');

        // 4. Handle view and return content
        $view = $this->getView('MyView');
        return $view->display('template');
    }
}

Asset Management Best Practices

// Group related assets
$doc->registerScript('app-vendor', '/js/vendor.min.js', [], '1.0', true);
$doc->registerScript('app-core', '/js/core.min.js', ['app-vendor'], '1.0', true);
$doc->registerScript('app-features', '/js/features.min.js', ['app-core'], '1.0', true);

// Use versioning for cache busting
$version = \Pramnos\Application\Settings::getSetting('app_version', '1.0.0');
$doc->registerScript('app-main', '/js/main.js', ['app-features'], $version, true);

// Conditional loading
if ($this->requiresDataTables()) {
    $doc->enqueueScript('datatables');
    $doc->enqueueStyle('datatables');
}

SEO Optimization

// Create SEO helper methods
private function setSEOMeta($title, $description, $keywords = '')
{
    $doc = \Pramnos\Framework\Factory::getDocument();

    // Title with site name
    $siteName = \Pramnos\Application\Settings::getSetting('sitename');
    $doc->title = $title . ' - ' . $siteName;

    // Description
    $doc->description = $description;

    // Keywords
    if ($keywords) {
        $doc->addMetaTag('keywords', $keywords, true);
    }

    // Open Graph
    $doc->og_title = $title;
    $doc->og_description = $description;
    $doc->og_site_name = $siteName;
    $doc->og_url = $this->getCurrentUrl();

    // Canonical URL
    $doc->setCanonical($this->getCurrentUrl());
}

Multi-Format Support

// Design controllers for multiple formats
public function display()
{
    $data = $this->getData();
    $format = $this->getRequestFormat();

    switch ($format) {
        case 'json':
            return $this->renderJson($data);
        case 'xml':
            return $this->renderXml($data);
        case 'print':
            return $this->renderPrintable($data);
        case 'rss':
            return $this->renderRss($data);
        default:
            return $this->renderHtml($data);
    }
}

private function renderJson($data)
{
    $doc = \Pramnos\Framework\Factory::getDocument('json');
    $doc->setContent(json_encode($data));
    return $doc->render();
}

Error Handling

// Handle document errors gracefully
try {
    $doc = \Pramnos\Framework\Factory::getDocument();
    $doc->title = 'Page Title';
    $content = $doc->render();
} catch (\Exception $e) {
    // Log error
    \Pramnos\Logs\Logger::error('Document rendering failed', [
        'error' => $e->getMessage(),
        'file' => $e->getFile(),
        'line' => $e->getLine()
    ]);

    // Fallback to simple output
    $content = '<html><body><h1>Service Unavailable</h1></body></html>';
}

echo $content;


The Pramnos Document & Output System provides a comprehensive foundation for managing multi-format content delivery with robust asset management, SEO optimization, and theme integration capabilities. This system enables you to build applications that serve content efficiently across multiple platforms and formats while maintaining clean separation between content, presentation, and business logic.