Skip to content

Commit d2f2cfa

Browse files
committed
perf: add caching layer for frequently accessed operations
Add instance and result caching for hot paths in the rendering pipeline: - ProjectNode: cache root document entry with O(1) lookup, add fast-path to getDocumentEntry(), lazy cache invalidation in setDocumentEntries() - DocumentNode: add hasDocumentEntry() helper for safe checks - RenderContext: use hash map for O(1) document lookup by file path, optimize withDocument() to clone directly instead of rebuilding - DocumentNameResolver: cache URI analysis and resolved paths - ExternalReferenceResolver: pre-compile schema regex pattern - SluggerAnchorNormalizer: cache slugger instance and normalization results - AbstractUrlGenerator: cache isRelativePath() checks - RelativeUrlGenerator: cache computed relative paths - PreNodeRendererFactory: cache renderer lookups by node class - TwigEnvironmentBuilder: enable filesystem template caching - TwigTemplateRenderer: cache global context to avoid redundant addGlobal() See https://cybottm.github.io/render-guides/ for benchmark data.
1 parent 8acd95a commit d2f2cfa

File tree

10 files changed

+158
-30
lines changed

10 files changed

+158
-30
lines changed

packages/guides/src/NodeRenderers/PreRenderers/PreNodeRendererFactory.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,16 @@
2121

2222
/**
2323
* Decorator to add pre-rendering logic to node renderers.
24+
*
25+
* Note: Caching assumes PreNodeRenderer::supports() only checks the node's
26+
* class type, not instance-specific properties. If a PreNodeRenderer needs
27+
* to check node properties, caching by class would return incorrect results.
2428
*/
2529
final class PreNodeRendererFactory implements NodeRendererFactory
2630
{
31+
/** @var array<class-string<Node>, NodeRenderer<Node>> */
32+
private array $cache = [];
33+
2734
public function __construct(
2835
private readonly NodeRendererFactory $innerFactory,
2936
/** @var iterable<PreNodeRenderer> */
@@ -33,6 +40,12 @@ public function __construct(
3340

3441
public function get(Node $node): NodeRenderer
3542
{
43+
// Cache by node class to avoid repeated preRenderer iteration
44+
$nodeFqcn = $node::class;
45+
if (isset($this->cache[$nodeFqcn])) {
46+
return $this->cache[$nodeFqcn];
47+
}
48+
3649
$preRenderers = [];
3750
foreach ($this->preRenderers as $preRenderer) {
3851
if (!$preRenderer->supports($node)) {
@@ -43,9 +56,9 @@ public function get(Node $node): NodeRenderer
4356
}
4457

4558
if (count($preRenderers) === 0) {
46-
return $this->innerFactory->get($node);
59+
return $this->cache[$nodeFqcn] = $this->innerFactory->get($node);
4760
}
4861

49-
return new PreRenderer($this->innerFactory->get($node), $preRenderers);
62+
return $this->cache[$nodeFqcn] = new PreRenderer($this->innerFactory->get($node), $preRenderers);
5063
}
5164
}

packages/guides/src/Nodes/DocumentNode.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,11 @@ public function getFootnoteTargetAnonymous(): FootnoteTarget|null
279279
return null;
280280
}
281281

282+
public function hasDocumentEntry(): bool
283+
{
284+
return $this->documentEntry !== null;
285+
}
286+
282287
public function getDocumentEntry(): DocumentEntryNode
283288
{
284289
if ($this->documentEntry === null) {

packages/guides/src/ReferenceResolvers/DocumentNameResolver.php

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,18 @@
2323

2424
final class DocumentNameResolver implements DocumentNameResolverInterface
2525
{
26+
/** @var array<string, string> */
27+
private array $absoluteUrlCache = [];
28+
29+
/** @var array<string, string> */
30+
private array $canonicalUrlCache = [];
31+
32+
/** @var array<string, bool> */
33+
private array $isAbsoluteCache = [];
34+
35+
/** @var array<string, bool> */
36+
private array $isAbsolutePathCache = [];
37+
2638
/**
2739
* Returns the absolute path, including prefixing '/'.
2840
*
@@ -31,20 +43,31 @@ final class DocumentNameResolver implements DocumentNameResolverInterface
3143
*/
3244
public function absoluteUrl(string $basePath, string $url): string
3345
{
34-
$uri = BaseUri::from($url);
35-
if ($uri->isAbsolute()) {
36-
return $url;
46+
$cacheKey = $basePath . '|' . $url;
47+
if (isset($this->absoluteUrlCache[$cacheKey])) {
48+
return $this->absoluteUrlCache[$cacheKey];
49+
}
50+
51+
// Cache URI analysis results separately by URL
52+
if (!isset($this->isAbsoluteCache[$url])) {
53+
$uri = BaseUri::from($url);
54+
$this->isAbsoluteCache[$url] = $uri->isAbsolute();
55+
$this->isAbsolutePathCache[$url] = $uri->isAbsolutePath();
56+
}
57+
58+
if ($this->isAbsoluteCache[$url]) {
59+
return $this->absoluteUrlCache[$cacheKey] = $url;
3760
}
3861

39-
if ($uri->isAbsolutePath()) {
40-
return $url;
62+
if ($this->isAbsolutePathCache[$url]) {
63+
return $this->absoluteUrlCache[$cacheKey] = $url;
4164
}
4265

4366
if ($basePath === '/') {
44-
return $basePath . $url;
67+
return $this->absoluteUrlCache[$cacheKey] = $basePath . $url;
4568
}
4669

47-
return '/' . trim($basePath, '/') . '/' . $url;
70+
return $this->absoluteUrlCache[$cacheKey] = '/' . trim($basePath, '/') . '/' . $url;
4871
}
4972

5073
/**
@@ -57,8 +80,13 @@ public function absoluteUrl(string $basePath, string $url): string
5780
*/
5881
public function canonicalUrl(string $basePath, string $url): string
5982
{
83+
$cacheKey = $basePath . '|' . $url;
84+
if (isset($this->canonicalUrlCache[$cacheKey])) {
85+
return $this->canonicalUrlCache[$cacheKey];
86+
}
87+
6088
if ($url[0] === '/') {
61-
return ltrim($url, '/');
89+
return $this->canonicalUrlCache[$cacheKey] = ltrim($url, '/');
6290
}
6391

6492
$dirNameParts = explode('/', $basePath);
@@ -78,6 +106,6 @@ public function canonicalUrl(string $basePath, string $url): string
78106
$urlPass1[] = $part;
79107
}
80108

81-
return ltrim(implode('/', $dirNameParts) . '/' . implode('/', $urlPass1), '/');
109+
return $this->canonicalUrlCache[$cacheKey] = ltrim(implode('/', $dirNameParts) . '/' . implode('/', $urlPass1), '/');
82110
}
83111
}

packages/guides/src/ReferenceResolvers/ExternalReferenceResolver.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ final class ExternalReferenceResolver implements ReferenceResolver
3737
public final const PRIORITY = -100;
3838
final public const SUPPORTED_SCHEMAS = '(?:aaa|aaas|about|acap|acct|acd|acr|adiumxtra|adt|afp|afs|aim|amss|android|appdata|apt|ar|ark|at|attachment|aw|barion|bb|beshare|bitcoin|bitcoincash|blob|bolo|browserext|cabal|calculator|callto|cap|cast|casts|chrome|chrome-extension|cid|coap|coap+tcp|coap+ws|coaps|coaps+tcp|coaps+ws|com-eventbrite-attendee|content|content-type|crid|cstr|cvs|dab|dat|data|dav|dhttp|diaspora|dict|did|dis|dlna-playcontainer|dlna-playsingle|dns|dntp|doi|dpp|drm|drop|dtmi|dtn|dvb|dvx|dweb|ed2k|eid|elsi|embedded|ens|ethereum|example|facetime|fax|feed|feedready|fido|file|filesystem|finger|first-run-pen-experience|fish|fm|ftp|fuchsia-pkg|geo|gg|git|gitoid|gizmoproject|go|gopher|graph|grd|gtalk|h323|ham|hcap|hcp|http|https|hxxp|hxxps|hydrazone|hyper|iax|icap|icon|im|imap|info|iotdisco|ipfs|ipn|ipns|ipp|ipps|irc|irc6|ircs|iris|iris\.beep|iris\.lwz|iris\.xpc|iris\.xpcs|isostore|itms|jabber|jar|jms|keyparc|lastfm|lbry|ldap|ldaps|leaptofrogans|lorawan|lpa|lvlt|magnet|mailserver|mailto|maps|market|matrix|message|microsoft\.windows\.camera|microsoft\.windows\.camera\.multipicker|microsoft\.windows\.camera\.picker|mid|mms|modem|mongodb|moz|ms-access|ms-appinstaller|ms-browser-extension|ms-calculator|ms-drive-to|ms-enrollment|ms-excel|ms-eyecontrolspeech|ms-gamebarservices|ms-gamingoverlay|ms-getoffice|ms-help|ms-infopath|ms-inputapp|ms-launchremotedesktop|ms-lockscreencomponent-config|ms-media-stream-id|ms-meetnow|ms-mixedrealitycapture|ms-mobileplans|ms-newsandinterests|ms-officeapp|ms-people|ms-project|ms-powerpoint|ms-publisher|ms-remotedesktop|ms-remotedesktop-launch|ms-restoretabcompanion|ms-screenclip|ms-screensketch|ms-search|ms-search-repair|ms-secondary-screen-controller|ms-secondary-screen-setup|ms-settings|ms-settings-airplanemode|ms-settings-bluetooth|ms-settings-camera|ms-settings-cellular|ms-settings-cloudstorage|ms-settings-connectabledevices|ms-settings-displays-topology|ms-settings-emailandaccounts|ms-settings-language|ms-settings-location|ms-settings-lock|ms-settings-nfctransactions|ms-settings-notifications|ms-settings-power|ms-settings-privacy|ms-settings-proximity|ms-settings-screenrotation|ms-settings-wifi|ms-settings-workplace|ms-spd|ms-stickers|ms-sttoverlay|ms-transit-to|ms-useractivityset|ms-virtualtouchpad|ms-visio|ms-walk-to|ms-whiteboard|ms-whiteboard-cmd|ms-word|msnim|msrp|msrps|mss|mt|mtqp|mumble|mupdate|mvn|news|nfs|ni|nih|nntp|notes|num|ocf|oid|onenote|onenote-cmd|opaquelocktoken|openpgp4fpr|otpauth|p1|pack|palm|paparazzi|payment|payto|pkcs11|platform|pop|pres|prospero|proxy|pwid|psyc|pttp|qb|query|quic-transport|redis|rediss|reload|res|resource|rmi|rsync|rtmfp|rtmp|rtsp|rtsps|rtspu|sarif|secondlife|secret-token|service|session|sftp|sgn|shc|shttp (OBSOLETE)|sieve|simpleledger|simplex|sip|sips|skype|smb|smp|sms|smtp|snews|snmp|soap\.beep|soap\.beeps|soldat|spiffe|spotify|ssb|ssh|starknet|steam|stun|stuns|submit|svn|swh|swid|swidpath|tag|taler|teamspeak|tel|teliaeid|telnet|tftp|things|thismessage|tip|tn3270|tool|turn|turns|tv|udp|unreal|upt|urn|ut2004|uuid-in-package|v-event|vemmi|ventrilo|ves|videotex|vnc|view-source|vscode|vscode-insiders|vsls|w3|wais|web3|wcr|webcal|web+ap|wifi|wpid|ws|wss|wtai|wyciwyg|xcon|xcon-userid|xfire|xmlrpc\.beep|xmlrpc\.beeps|xmpp|xri|ymsgr|z39\.50|z39\.50r|z39\.50s)';
3939

40+
private const SCHEMA_PATTERN_REGEX = '/^' . self::SUPPORTED_SCHEMAS . '$/';
41+
4042
public function resolve(LinkInlineNode $node, RenderContext $renderContext, Messages $messages): bool
4143
{
4244
if (str_starts_with($node->getTargetReference(), '#')) {
@@ -52,7 +54,7 @@ public function resolve(LinkInlineNode $node, RenderContext $renderContext, Mess
5254
}
5355

5456
$url = parse_url($node->getTargetReference(), PHP_URL_SCHEME);
55-
if ($url !== null && $url !== false && preg_match('/^' . self::SUPPORTED_SCHEMAS . '$/', $url)) {
57+
if ($url !== null && $url !== false && preg_match(self::SCHEMA_PATTERN_REGEX, $url)) {
5658
$node->setUrl($node->getTargetReference());
5759

5860
return true;

packages/guides/src/ReferenceResolvers/SluggerAnchorNormalizer.php

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,28 @@
1919

2020
final class SluggerAnchorNormalizer implements AnchorNormalizer
2121
{
22+
private AsciiSlugger|null $slugger = null;
23+
24+
/** @var array<string, string> */
25+
private array $cache = [];
26+
2227
public function reduceAnchor(string $rawAnchor): string
2328
{
24-
$slugger = new AsciiSlugger();
25-
$slug = $slugger->slug($rawAnchor);
29+
// Check cache first - same anchors are resolved many times
30+
if (isset($this->cache[$rawAnchor])) {
31+
return $this->cache[$rawAnchor];
32+
}
33+
34+
if ($this->slugger === null) {
35+
$this->slugger = new AsciiSlugger();
36+
}
37+
38+
$slug = $this->slugger->slug($rawAnchor);
39+
$result = strtolower($slug->toString());
40+
41+
// Cache the result for future calls
42+
$this->cache[$rawAnchor] = $result;
2643

27-
return strtolower($slug->toString());
44+
return $result;
2845
}
2946
}

packages/guides/src/RenderContext.php

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ class RenderContext
3030
/** @var DocumentNode[] */
3131
private array $allDocuments;
3232

33+
/** @var array<string, DocumentNode> */
34+
private array $documentsByFile = [];
35+
3336
private string $outputFilePath = '';
3437

3538
private Renderer\DocumentListIterator $iterator;
@@ -44,7 +47,10 @@ private function __construct(
4447
) {
4548
}
4649

47-
/** @param DocumentNode[] $allDocumentNodes */
50+
/**
51+
* @param DocumentNode[] $allDocumentNodes
52+
* @param array<string, DocumentNode>|null $documentsByFile Pre-built hash map for reuse
53+
*/
4854
public static function forDocument(
4955
DocumentNode $documentNode,
5056
array $allDocumentNodes,
@@ -53,6 +59,7 @@ public static function forDocument(
5359
string $destinationPath,
5460
string $ouputFormat,
5561
ProjectNode $projectNode,
62+
array|null $documentsByFile = null,
5663
): self {
5764
$self = new self(
5865
$destinationPath,
@@ -65,22 +72,40 @@ public static function forDocument(
6572

6673
$self->document = $documentNode;
6774
$self->allDocuments = $allDocumentNodes;
68-
$self->outputFilePath = $documentNode->getFilePath() . '.' . $ouputFormat;
75+
76+
// Use pre-built hash map if provided, otherwise build it
77+
if ($documentsByFile !== null) {
78+
$self->documentsByFile = $documentsByFile;
79+
} else {
80+
foreach ($allDocumentNodes as $doc) {
81+
if (!$doc->hasDocumentEntry()) {
82+
continue;
83+
}
84+
85+
$self->documentsByFile[$doc->getDocumentEntry()->getFile()] = $doc;
86+
}
87+
}
88+
89+
$self->outputFilePath = $documentNode->getFilePath() . '.' . $ouputFormat;
6990

7091
return $self;
7192
}
7293

7394
public function withDocument(DocumentNode $documentNode): self
7495
{
75-
return self::forDocument(
96+
// Pass existing hash map to avoid redundant construction
97+
$context = self::forDocument(
7698
$documentNode,
7799
$this->allDocuments,
78100
$this->origin,
79101
$this->destination,
80102
$this->destinationPath,
81103
$this->outputFormat,
82104
$this->projectNode,
83-
)->withIterator($this->getIterator());
105+
$this->documentsByFile,
106+
);
107+
108+
return $context->withIterator($this->getIterator());
84109
}
85110

86111
public function getDocument(): DocumentNode
@@ -121,6 +146,14 @@ public static function forProject(
121146
);
122147

123148
$self->allDocuments = $allDocumentNodes;
149+
// Build hash map for O(1) document lookup
150+
foreach ($allDocumentNodes as $doc) {
151+
if (!$doc->hasDocumentEntry()) {
152+
continue;
153+
}
154+
155+
$self->documentsByFile[$doc->getDocumentEntry()->getFile()] = $doc;
156+
}
124157

125158
return $self;
126159
}
@@ -222,10 +255,10 @@ public function getProjectNode(): ProjectNode
222255

223256
public function getDocumentNodeForEntry(DocumentEntryNode $entryNode): DocumentNode
224257
{
225-
foreach ($this->allDocuments as $child) {
226-
if ($child->getDocumentEntry() === $entryNode) {
227-
return $child;
228-
}
258+
// O(1) lookup using hash map instead of O(n) iteration
259+
$file = $entryNode->getFile();
260+
if (isset($this->documentsByFile[$file])) {
261+
return $this->documentsByFile[$file];
229262
}
230263

231264
throw new Exception('No document was found for document entry ' . $entryNode->getFile());

packages/guides/src/Renderer/UrlGenerator/AbstractUrlGenerator.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626

2727
abstract class AbstractUrlGenerator implements UrlGeneratorInterface
2828
{
29+
/** @var array<string, bool> */
30+
private array $relativeUrlCache = [];
31+
2932
public function __construct(private readonly DocumentNameResolverInterface $documentNameResolver)
3033
{
3134
}
@@ -93,7 +96,11 @@ public function generateInternalUrl(
9396

9497
private function isRelativeUrl(string $url): bool
9598
{
96-
return BaseUri::from($url)->isRelativePath();
99+
if (isset($this->relativeUrlCache[$url])) {
100+
return $this->relativeUrlCache[$url];
101+
}
102+
103+
return $this->relativeUrlCache[$url] = BaseUri::from($url)->isRelativePath();
97104
}
98105

99106
public function getCurrentFileUrl(RenderContext $renderContext): string

packages/guides/src/Renderer/UrlGenerator/RelativeUrlGenerator.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,28 @@
2626

2727
final class RelativeUrlGenerator extends AbstractUrlGenerator
2828
{
29+
/** @var array<string, string> */
30+
private array $pathCache = [];
31+
2932
public function generateInternalPathFromRelativeUrl(
3033
RenderContext $renderContext,
3134
string $canonicalUrl,
3235
): string {
33-
$currentPathUri = Uri::new($renderContext->getOutputFilePath());
36+
$outputFilePath = $renderContext->getOutputFilePath();
37+
$cacheKey = $outputFilePath . '|' . $canonicalUrl;
38+
39+
if (isset($this->pathCache[$cacheKey])) {
40+
return $this->pathCache[$cacheKey];
41+
}
42+
43+
$currentPathUri = Uri::new($outputFilePath);
3444
$canonicalUrlUri = Uri::new($canonicalUrl);
3545

3646
$canonicalAnchor = $canonicalUrlUri->getFragment();
3747

3848
// If the paths are the same, include the anchor
3949
if ($currentPathUri->getPath() === $canonicalUrlUri->getPath()) {
40-
return '#' . $canonicalAnchor;
50+
return $this->pathCache[$cacheKey] = '#' . $canonicalAnchor;
4151
}
4252

4353
// Split paths into arrays
@@ -66,6 +76,6 @@ public function generateInternalPathFromRelativeUrl(
6676
$relativePath .= '#' . $canonicalAnchor;
6777
}
6878

69-
return $relativePath;
79+
return $this->pathCache[$cacheKey] = $relativePath;
7080
}
7181
}

packages/guides/src/Twig/EnvironmentBuilder.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@
1515

1616
use phpDocumentor\Guides\RenderContext;
1717
use phpDocumentor\Guides\Twig\Theme\ThemeManager;
18+
use Twig\Cache\FilesystemCache;
1819
use Twig\Environment;
1920
use Twig\Extension\DebugExtension;
2021
use Twig\Extension\ExtensionInterface;
2122

23+
use function sys_get_temp_dir;
24+
2225
final class EnvironmentBuilder
2326
{
2427
private Environment $environment;
@@ -28,7 +31,10 @@ public function __construct(ThemeManager $themeManager, iterable $extensions = [
2831
{
2932
$this->environment = new Environment(
3033
$themeManager->getFilesystemLoader(),
31-
['debug' => true],
34+
[
35+
'debug' => true,
36+
'cache' => new FilesystemCache(sys_get_temp_dir() . '/guides-twig-cache'),
37+
],
3238
);
3339
$this->environment->addExtension(new DebugExtension());
3440

0 commit comments

Comments
 (0)