├── .env.example ├── .gitignore ├── README.md ├── TODO.md ├── bin ├── alfresco └── help.txt ├── bootstrap.php ├── build ├── cache │ └── .gitignore ├── indexes │ └── .gitignore └── output │ └── .gitignore ├── composer.json ├── meilisearch.json ├── package.json ├── phpstan-baseline.neon ├── phpstan.neon ├── pint.json ├── postcss.config.js ├── resources ├── components │ ├── authors.php │ ├── caution.php │ ├── editors.php │ ├── emphasised-literal.php │ ├── function-popup.php │ ├── generic.heading-code.php │ ├── icon.external-link.php │ ├── icon.link.php │ ├── inline-code.php │ ├── link.php │ ├── main.php │ ├── menu-list.php │ ├── menu.php │ ├── note.php │ ├── note.title.php │ ├── ordered-list.php │ ├── paragraph.php │ ├── program-listing.php │ ├── screen.php │ ├── tip.php │ ├── tip.title.php │ ├── title.php │ ├── unordered-list.php │ └── warning.php ├── footer.html ├── header.html ├── replacements │ └── .gitignore ├── script.js ├── style.css └── translations │ └── en │ └── ui.php ├── src ├── Contracts │ ├── DependsOnIndexes.php │ ├── Generator.php │ └── Slotable.php ├── Manual │ ├── Factory.php │ ├── Manual.php │ └── Node.php ├── Output.php ├── Process.php ├── Render │ ├── Factory.php │ ├── Highlighter.php │ ├── HtmlString.php │ ├── HtmlTag.php │ ├── Replacer.php │ └── Wrapper.php ├── Stream │ ├── FileStreamFactory.php │ ├── State.php │ └── Stream.php ├── Support │ ├── Link.php │ └── Translator.php └── Website │ ├── EmptyChunkIndex.php │ ├── FunctionIndex.php │ ├── Generator.php │ ├── Image.php │ ├── ImageIndex.php │ ├── Method.php │ ├── Parameter.php │ ├── Title.php │ └── TitleIndex.php ├── tailwind.config.js ├── theme.json └── vite.config.js /.env.example: -------------------------------------------------------------------------------- 1 | VITE_MEILISEARCH_HOST_URL="http://127.0.0.1:7700" 2 | VITE_MEILISEARCH_API_KEY="LARAVEL-HERD" 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /composer.lock 3 | /vendor 4 | /package-lock.json 5 | /node_modules 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍃 Alfresco 2 | 3 | A fresh take on the PHP documentation website generation. 4 | 5 | > [!IMPORTANT] 6 | > This project is a work in progress. Follow along with thr project in this [Twitter / X](https://x.com/timacdonald87/status/1631504755225919489) thread. 7 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - [ ] Ensure titles are the correct level, i.e., h1, h2, etc. 4 | - [ ] Render a `x` for all variables with IDE-like pop up. [POC](https://twitter.com/timacdonald87/status/1647049647729553408). 5 | - [ ] Render a `f` for all functions with IDE-like pop up. [POC](https://twitter.com/timacdonald87/status/1647049647729553408). 6 | - [ ] Render a `C` for all constants with IDE-like pop up. [POC](https://twitter.com/timacdonald87/status/1647049647729553408). 7 | - [ ] Abbreviations and Acronyms should have a title where they aren't described within the sentence. 8 | - [ ] Ensure examples using `figure` scales. 9 | - [ ] The "PHP Manual" navigation item, the "PHP Manual" title on the preface page, and the "PHP Manual" HTML page need to be manually omitted. 10 | - [x] The `code` tags are forcing the font size when used in other elements, such as headings. Need a better way to make code blocks small while allowing parents to override. 11 | - [ ] Pull "see also" up into sidebar 12 | - [ ] The homepage should contain the menu in full completely expanded. 13 | - [ ] Empty pages should include a redirect to their overview page. 14 | - [ ] Links should retain their color even when they contain a `code` snippet. Currently the inline code overrides the link color. 15 | - [ ] We should have a dedicated `class` property to accompany `attributes` 16 | - [ ] Function index not handling parameter union types correctly. 17 | - [ ] function index needs to handle tags within method descriptions, like we do in titles. 18 | - [ ] I think a review of the `programlisting` and `screen` tags to ensure they are a) the correct tag to be used and b) have a role where applicable. 19 | - [ ] Images, function popups, and titles need cleanup and tightening 20 | - [ ] show line numbers when showing code snippet blocks 21 | -------------------------------------------------------------------------------- /bin/alfresco: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | skip(1) 35 | ->filter(fn ($value) => str_starts_with($value, '-')) 36 | ->map(fn ($option) => with(explode('=', $option), fn ($bits) => [ 37 | 'name' => $bits[0], 38 | 'value' => $bits[1] ?? true, 39 | ])); 40 | 41 | // Input helpers... 42 | 43 | $option = fn ($key): null|string|bool => $options->firstWhere('name', $key)['value'] ?? null; 44 | $options = fn ($key): Collection => $options->where('name', $key)->pluck('value'); 45 | 46 | /* 47 | * Adjust config based on CLI input. 48 | */ 49 | 50 | $option('--debug') && $container->make(Configuration::class)->set(['debug' => true]); 51 | 52 | /* 53 | * Output help information when requested. 54 | */ 55 | 56 | $output = $container->make(Output::class); 57 | 58 | if ($option('--help')) { 59 | $output->write(file_get_contents(__DIR__.'/help.txt')); 60 | exit; 61 | } 62 | 63 | /* 64 | * Ensure required arguments are provided. 65 | */ 66 | 67 | if (! $option('--manual')) { 68 | $container->make(Output::class)->write('The [--manual] argument is required.'); 69 | exit(1); 70 | } 71 | 72 | /* 73 | * Here we go. 74 | */ 75 | 76 | $output->line('🍃 Alfresco'); 77 | 78 | /* 79 | * Resolve generators and their indexes. 80 | */ 81 | 82 | [$generators, $indexes] = $options('--generator') 83 | ->whenEmpty(fn () => collect([Alfresco\Website\Generator::class])) 84 | ->map(fn ($class) => $container->make($class)) 85 | ->pipe(fn ($generators) => [ 86 | $generators, 87 | $generators->whereInstanceOf(DependsOnIndexes::class) 88 | ->flatMap(fn ($generator) => $generator->indexes()), 89 | ]); 90 | 91 | /* 92 | * Resolve the manual factory and process instances we need to run the generators. 93 | */ 94 | 95 | [$factory, $process, $measure] = [ 96 | $container->make(Factory::class), 97 | $container->make(Process::class), 98 | fn ($callback) => round(Benchmark::measure($callback) / 1000, 2), // @phpstan-ignore binaryOp.invalid 99 | ]; 100 | 101 | /* 102 | * Run indexes. 103 | * 104 | * The indexes perform an initial loop over the documentation to perform any 105 | * useful read-ahead actions. This information can then be used by their 106 | * generators to make decisons based on data it has not yet seen. 107 | */ 108 | 109 | if ($option('--no-index')) { 110 | $output->line('Skipping indexing.'); 111 | } else { 112 | $output->write('Indexing'); 113 | 114 | $duration = $measure(fn () => $process->handle($factory->make($option('--manual')), $indexes, function ($node, $i) use ($option, $output) { 115 | if ($option('--step-debug')) { 116 | $output->write('Hit ENTER to process node: '); 117 | $output->write("<{$node->name}"); 118 | $output->write(' id="'.($node->hasId() ? $node->id() : null).'"'); 119 | $output->write(' depth="'.$node->depth.'"'); 120 | $output->write('>'); 121 | fgets(STDIN); 122 | } elseif ($i % 5000 === 0) { 123 | $output->write('.'); 124 | } 125 | })); 126 | 127 | $output->line("Indexing completed in {$duration} seconds."); 128 | } 129 | 130 | /* 131 | * Run the generators. 132 | */ 133 | 134 | if ($option('--no-generation')) { 135 | $output->line('Skipping generation.'); 136 | } else { 137 | $output->write('Generating'); 138 | 139 | $duration = $measure(fn () => $process->handle($factory->make($option('--manual')), $generators, function ($node, $i) use ($option, $output) { 140 | if ($option('--step-debug')) { 141 | $output->write('Hit ENTER to process node: '); 142 | $output->write("<{$node->name}"); 143 | $output->write(' id="'.($node->hasId() ? $node->id() : null).'"'); 144 | $output->write(' depth="'.$node->depth.'"'); 145 | $output->write('>'); 146 | fgets(STDIN); 147 | } elseif ($i % 5000 === 0) { 148 | $output->write('.'); 149 | } 150 | })); 151 | 152 | $output->line("Generation completed in {$duration} seconds."); 153 | } 154 | 155 | $output->line('Done.'); 156 | -------------------------------------------------------------------------------- /bin/help.txt: -------------------------------------------------------------------------------- 1 | 2 | 🍃 Alfresco 3 | A fresh take on the PHP documentation website generation. 4 | 5 | USAGE 6 | 7 | alfresco [--options] [generators...] 8 | 9 | OPTIONS 10 | 11 | --manual 12 | Path to the already generated manual file. 13 | Usage: alfresco --manual=../doc-base/manual.xml 14 | 15 | --no-generation 16 | Do not run the generator. Can be useful to re-generate only the indexes. 17 | Usage: alfresco --no-generation 18 | 19 | --no-index 20 | Do not run the generator's indexes. Can be useful if wanting to re-generate but the indexes are not stale. 21 | Usage: alfresco --no-index 22 | 23 | --debug 24 | Turn on debug generator output. 25 | Usage: alfresco --debug 26 | 27 | --verbose 28 | Turn on verbose build output. 29 | Usage: alfresco --debug 30 | -------------------------------------------------------------------------------- /bootstrap.php: -------------------------------------------------------------------------------- 1 | instance(Container::class, $container); 15 | 16 | $container->singleton(Configuration::class, fn () => new Configuration([ 17 | 'debug' => false, 18 | 'language' => 'en', 19 | 'root_directory' => __DIR__, 20 | 'build_directory' => __DIR__.'/build/output', 21 | 'cache_directory' => __DIR__.'/build/cache', 22 | 'resource_directory' => __DIR__.'/resources', 23 | 'index_directory' => __DIR__.'/build/indexes', 24 | 'component_directory' => __DIR__.'/resources/components', 25 | 'translation_directory' => __DIR__.'/resources/translations', 26 | 'replacements_directory' => __DIR__.'/resources/replacements', 27 | ])); 28 | 29 | $container->singleton(Factory::class); 30 | 31 | $container->bind(Shiki::class, fn (Container $container) => new Shiki( 32 | $container->make(Configuration::class)->get('root_directory').'/theme.json', 33 | )); 34 | 35 | $container->bind(FileLoader::class, fn (Container $container) => new FileLoader( 36 | $container->make(Filesystem::class), 37 | $container->make(Configuration::class)->get('translation_directory'), 38 | )); 39 | 40 | $container->singleton(Translator::class, fn (Container $container) => (new Translator( 41 | $container->make(FileLoader::class), 42 | 'en', 43 | ))->handleMissingKeysUsing(function ($key, $replace, $locale, $fallback) { 44 | throw new RuntimeException("Missing translation [{$key}] for locale [{$locale}]."); 45 | })); 46 | }); 47 | -------------------------------------------------------------------------------- /build/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /build/indexes/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /build/output/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "timacdonald/alfresco", 3 | "license": "MIT", 4 | "authors": [ 5 | { 6 | "name": "Tim MacDonald", 7 | "email": "hello@timacdonald.me" 8 | } 9 | ], 10 | "require": { 11 | "php": "8.3.*", 12 | "illuminate/config": "^11", 13 | "illuminate/container": "^11", 14 | "illuminate/filesystem": "^11", 15 | "illuminate/support": "^11", 16 | "illuminate/translation": "^11", 17 | "laravel/pint": "^1.18", 18 | "laravel/serializable-closure": "^1.3", 19 | "spatie/shiki-php": "^1.3", 20 | "symfony/process": "^7.1", 21 | "thecodingmachine/safe": "^2.5" 22 | }, 23 | "require-dev": { 24 | "phpstan/phpstan": "^1.10", 25 | "symfony/var-dumper": "^6.2", 26 | "thecodingmachine/phpstan-safe-rule": "^1.2" 27 | }, 28 | "autoload": { 29 | "psr-4": { 30 | "Alfresco\\": "src" 31 | } 32 | }, 33 | "config": { 34 | "optimize-autoloader": true, 35 | "preferred-install": "dist", 36 | "sort-packages": true 37 | }, 38 | "minimum-stability": "stable", 39 | "prefer-stable": true 40 | } 41 | -------------------------------------------------------------------------------- /meilisearch.json: -------------------------------------------------------------------------------- 1 | { 2 | "index_uid": "docs", 3 | "start_urls": ["http://127.0.0.1:8080/"], 4 | "sitemap_urls": [], 5 | "stop_urls": [], 6 | "allowed_domains": ["127.0.0.1"], 7 | "selectors": { 8 | "lvl0": { 9 | "selector": "main + nav > ul > details[open] summary", 10 | "global": true, 11 | "default_value": "Documentation" 12 | }, 13 | "lvl1": { 14 | "selector": "main + nav > ul > details[open] details[open] summary", 15 | "global": true, 16 | "default_value": "Sub-chapter" 17 | }, 18 | "lvl2": "main h1", 19 | "lvl3": "main h2", 20 | "lvl4": "main h3", 21 | "lvl5": "main h4", 22 | "lvl6": "main h5", 23 | "text": "main" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "scripts": { 4 | "build": "vite build", 5 | "watch": "vite build --watch" 6 | }, 7 | "dependencies": { 8 | "@floating-ui/dom": "^1.6.11", 9 | "autoprefixer": "^10.4.13", 10 | "meilisearch-docsearch": "^0.6.0", 11 | "postcss": "^8.4.31", 12 | "shiki": "^0.14.3", 13 | "tailwindcss": "^3.3.1", 14 | "vite": "^5.2.13" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timacdonald/alfresco/73e5e496e9de2f313482a6fe35f077fd6c62aec3/phpstan-baseline.neon -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 8 3 | paths: 4 | - bin/alfresco 5 | - resources/components 6 | - src 7 | includes: 8 | - ./phpstan-baseline.neon 9 | - ./vendor/thecodingmachine/phpstan-safe-rule/phpstan-safe-rule.neon 10 | -------------------------------------------------------------------------------- /pint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "blank_line_between_import_groups": false, 4 | "declare_strict_types": true, 5 | "ordered_imports": {"sort_algorithm": "length"}, 6 | "strict_param": true, 7 | "trailing_comma_in_multiline": { 8 | "after_heredoc": true, 9 | "elements": ["arguments", "array_destructuring", "arrays", "match", "parameters"] 10 | }, 11 | "explicit_string_variable": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /resources/components/authors.php: -------------------------------------------------------------------------------- 1 | $render->wrapper( 14 | before: $render->tag( 15 | as: 'p', 16 | class: 'my-6 first:mt-0 last:mb-0', 17 | before: $translator->get('ui.authors.by').':', 18 | ), 19 | slot: $render->component('unordered-list'), 20 | after: $render->tag( 21 | as: 'div', 22 | class: 'mb-6', 23 | ), 24 | ); 25 | -------------------------------------------------------------------------------- /resources/components/caution.php: -------------------------------------------------------------------------------- 1 | $render->tag( 14 | as: 'div', 15 | class: 'bg-orange-50/50 my-6 first:mt-0 last:mb-0 p-6 rounded border border-orange-100 text-orange-950 relative', 16 | attributes: [ 17 | 'aria-role' => 'note', 18 | ], 19 | before: $render->tag( 20 | as: 'strong', 21 | class: 'absolute inline-block right-3 -top-3 rounded bg-orange-200 leading-none py-1 px-2 text-sm font-semibold font-mono uppercase', 22 | before: $translator->get('ui.caution.badge'), 23 | ), 24 | // We wrap this in a `div` to ensure that the first / last element 25 | // margin changes are not impacted by the "caution" badge we 26 | // append. 27 | slot: $render->tag('div'), 28 | ); 29 | -------------------------------------------------------------------------------- /resources/components/editors.php: -------------------------------------------------------------------------------- 1 | $render->wrapper( 14 | before: $render->inlineText($translator->get('ui.editors.by')), 15 | after: '.', 16 | ); 17 | -------------------------------------------------------------------------------- /resources/components/emphasised-literal.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'em', 13 | class: 'not-italic', 14 | slot: $render->component('inline-code'), 15 | ); 16 | -------------------------------------------------------------------------------- /resources/components/function-popup.php: -------------------------------------------------------------------------------- 1 | $render->html(<<<'HTML' 13 | 14 | {$parameter->types->implode('|')} 15 | 16 | 17 | \${$parameter->name} 18 | 19 | HTML); 20 | 21 | return function ( 22 | string $id, 23 | Method $method, 24 | Factory $render, 25 | ) use ($parameter) { 26 | $description = Str::of($method->description)->trim()->finish('.'); 27 | 28 | return $render->html(<< 30 |
/**
31 |      * {$description}
32 |      */
33 | 34 | {$method->name} 35 | 36 | ({$method->parameters->map(fn (Parameter $p) => $render->component($parameter, ['parameter' => $p]))->implode(', ')}): 37 | 38 | {$method->returnTypes->implode('|')} 39 | 40 | 41 | HTML 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /resources/components/generic.heading-code.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'code', 13 | class: 'font-mono', 14 | ); 15 | -------------------------------------------------------------------------------- /resources/components/icon.external-link.php: -------------------------------------------------------------------------------- 1 | << 13 | {$translator->get('ui.link.external')} 14 | 15 | 16 | HTML; 17 | -------------------------------------------------------------------------------- /resources/components/icon.link.php: -------------------------------------------------------------------------------- 1 | <<<'HTML' 6 | 7 | 8 | 9 | 10 | HTML; 11 | -------------------------------------------------------------------------------- /resources/components/inline-code.php: -------------------------------------------------------------------------------- 1 | $render->tag( 15 | as: $as, 16 | class: [ 17 | 'text-[0.875em] not-italic bg-violet-950/5 text-violet-950 rounded leading-none py-1 px-1.5 inline-block font-mono', 18 | ...(is_array($class) ? $class : [$class]), 19 | ], 20 | attributes: $attributes, 21 | ); 22 | -------------------------------------------------------------------------------- /resources/components/link.php: -------------------------------------------------------------------------------- 1 | $render->tag( 15 | as: 'a', 16 | class: [ 17 | 'text-violet-700 hover:underline group', 18 | $link->isInternal ? '' : 'inline-flex items-center', 19 | ], 20 | attributes: [ 21 | 'href' => $link->isInternal 22 | ? "{$link->destination}.html" 23 | : $link->destination, 24 | 'rel' => $link->isInternal 25 | ? 'prefetch' 26 | : false, 27 | ], 28 | before: $text, 29 | after: $link->isInternal ? '' : $render->component('icon.external-link'), 30 | ); 31 | -------------------------------------------------------------------------------- /resources/components/main.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'main', 13 | class: 'max-w-4xl w-full px-12 pb-8 pt-[23px] text-slate-800 leading-relaxed text-base font-normal', 14 | ); 15 | -------------------------------------------------------------------------------- /resources/components/menu-list.php: -------------------------------------------------------------------------------- 1 | $items->map(fn (Title $title) => $title->children->isEmpty() 17 | ? ($empty->contains(fn (Title $empty) => $title->is($empty)) ? '' : $render->tag( 18 | as: 'div', 19 | class: [ 20 | 'relative flex items-center pl-4 py-1.5 before:block before:h-1.5 before:w-1.5 before:rounded-full before:absolute before:left-[3px]', 21 | $title->is($active) ? 'before:bg-violet-600' : 'before:bg-violet-300', 22 | ], 23 | before: $render->tag( 24 | as: 'a', 25 | class: [ 26 | 'text-sm relative flex items-center', 27 | $title->is($active) ? 'text-violet-700 font-bold' : 'text-violet-950', 28 | ], 29 | attributes: [ 30 | 'href' => "{$title->id}.html", 31 | ], 32 | before: $title->html, 33 | ), 34 | )->toString()) 35 | : $render->tag( 36 | as: 'details', 37 | class: 'pl-4', 38 | attributes: [ 39 | 'open' => $title->isOrHasChild($active), 40 | ], 41 | before: $render->tag( 42 | as: 'summary', 43 | class: 'cursor-pointer py-1.5 list-outside text-violet-950 text-sm marker:text-violet-300', 44 | before: $title->html, 45 | ), 46 | after: ($empty->contains(fn (Title $empty) => $title->is($empty)) 47 | ? '' 48 | : $render->tag( 49 | as: 'div', 50 | class: [ 51 | 'relative flex items-center pl-4 py-1.5 before:block before:h-1.5 before:w-1.5 before:rounded-full before:absolute before:left-[3px]', 52 | $title->is($active) ? 'before:bg-violet-600' : 'before:bg-violet-300', 53 | ], 54 | before: $render->tag( 55 | as: 'a', 56 | class: [ 57 | 'text-sm relative flex items-center', 58 | $title->is($active) ? 'text-violet-700 font-bold' : 'text-violet-950', 59 | ], 60 | attributes: [ 61 | 'href' => "{$title->id}.html", 62 | ], 63 | before: $title->html.' overview', 64 | ), 65 | )).$render->component('menu-list', [ 66 | 'items' => $title->children, 67 | 'active' => $active, 68 | 'empty' => $empty, 69 | ]), 70 | )->toString())->join(''); 71 | -------------------------------------------------------------------------------- /resources/components/menu.php: -------------------------------------------------------------------------------- 1 | $render->tag( 17 | as: 'nav', 18 | class: 'order-first py-4 pl-10 pr-6 w-[300px] bg-violet-25 border-r border-violet-100', 19 | attributes: [ 20 | 'id' => 'main-nav', 21 | ], 22 | before: $render->tag( 23 | as: 'div', 24 | class: 'list-none my-6 first:mt-0 last:mb-0 -ml-4', 25 | before: $render->component('menu-list', [ 26 | 'items' => $items, 27 | 'active' => $active, 28 | 'empty' => $empty, 29 | ]), 30 | ), 31 | ); 32 | -------------------------------------------------------------------------------- /resources/components/note.php: -------------------------------------------------------------------------------- 1 | $render->tag( 14 | as: 'div', 15 | class: 'bg-blue-50/50 my-6 first:mt-0 last:mb-0 p-6 rounded border border-blue-100 text-blue-950 relative', 16 | attributes: [ 17 | 'aria-role' => 'note', 18 | ], 19 | before: $render->tag( 20 | as: 'strong', 21 | class: 'absolute inline-block right-3 -top-3 rounded bg-blue-200 leading-none py-1 px-2 text-sm font-semibold font-mono uppercase', 22 | before: $translator->get('ui.note.badge'), 23 | ), 24 | // We wrap this in a `div` to ensure that the first / last element 25 | // margin changes are not impacted by the "note" badge we 26 | // append. 27 | slot: $render->tag('div'), 28 | ); 29 | -------------------------------------------------------------------------------- /resources/components/note.title.php: -------------------------------------------------------------------------------- 1 | $render->tag( 14 | as: 'h3', 15 | class: 'text-xl text-blue-950 items-center text-lg font-bold leading-none inline-block', 16 | attributes: [ 17 | 'id' => $link->destinationWithoutFragmentHash(), 18 | ], 19 | slot: $render->tag( 20 | as: 'a', 21 | class: 'inline-flex items-center relative group hover:underline', 22 | attributes: [ 23 | 'href' => $link->destination, 24 | ], 25 | after: $render->tag( 26 | as: 'span', 27 | class: 'hidden items-center justify-center group-hover:flex leading-none absolute w-5 h-full -right-7 top-0', 28 | attributes: [ 29 | 'aria-hidden' => 'true', 30 | ], 31 | after: $render->component('icon.link'), 32 | ), 33 | ), 34 | ); 35 | -------------------------------------------------------------------------------- /resources/components/ordered-list.php: -------------------------------------------------------------------------------- 1 | $render->tag( 13 | as: 'ol', 14 | class: 'list-decimal pl-5 my-2 last:mb-0 space-y-2', 15 | attributes: [ 16 | 'type' => $type, 17 | ], 18 | ); 19 | -------------------------------------------------------------------------------- /resources/components/paragraph.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'p', 13 | class: 'my-6 first:mt-0 last:mb-0', 14 | ); 15 | -------------------------------------------------------------------------------- /resources/components/program-listing.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'div', 13 | class: 'border border-violet-100 bg-violet-25 px-6 py-5 leading-7 rounded my-6 first:mt-0 last:mb-0 relative', 14 | before: <<<'SVG' 15 | 18 | SVG, 19 | slot: $render->tag( 20 | as: 'pre', 21 | class: 'overflow-x-auto', 22 | slot: $render->tag( 23 | as: 'code', 24 | class: 'font-mono', 25 | ), 26 | ), 27 | ); 28 | -------------------------------------------------------------------------------- /resources/components/screen.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'div', 13 | class: 'border border-violet-100 bg-violet-25 px-6 py-5 leading-7 rounded my-6 first:mt-0 last:mb-0 relative ', 14 | slot: $render->tag( 15 | as: 'pre', 16 | class: 'overflow-x-auto', 17 | slot: $render->tag( 18 | as: 'code', 19 | class: 'font-mono text-slate-950', 20 | ), 21 | ), 22 | ); 23 | -------------------------------------------------------------------------------- /resources/components/tip.php: -------------------------------------------------------------------------------- 1 | $render->tag( 14 | as: 'div', 15 | class: 'bg-green-50/50 my-6 first:mt-0 last:mb-0 p-6 rounded border border-green-200 text-green-950 relative', 16 | attributes: [ 17 | 'aria-role' => 'note', 18 | ], 19 | before: $render->tag( 20 | as: 'strong', 21 | class: 'absolute inline-block right-3 -top-3 rounded bg-green-200 leading-none py-1 px-2 text-sm font-semibold font-mono uppercase', 22 | before: $translator->get('ui.tip.badge'), 23 | ), 24 | // We wrap this in a `div` to ensure that the first / last element 25 | // margin changes are not impacted by the "note" badge we 26 | // append. 27 | slot: $render->tag('div'), 28 | ); 29 | -------------------------------------------------------------------------------- /resources/components/tip.title.php: -------------------------------------------------------------------------------- 1 | $render->tag( 14 | as: 'h3', 15 | class: 'text-xl text-blue-950 items-center text-lg font-bold leading-none inline-block', 16 | attributes: [ 17 | 'id' => $link->destinationWithoutFragmentHash(), 18 | ], 19 | slot: $render->tag( 20 | as: 'a', 21 | class: 'inline-flex items-center relative group hover:underline', 22 | attributes: [ 23 | 'href' => $link->destination, 24 | ], 25 | after: $render->tag( 26 | as: 'span', 27 | class: 'hidden items-center justify-center group-hover:flex leading-none absolute w-5 h-full -right-7 top-0', 28 | attributes: [ 29 | 'aria-hidden' => 'true', 30 | ], 31 | after: $render->component('icon.link'), 32 | ), 33 | ), 34 | ); 35 | -------------------------------------------------------------------------------- /resources/components/title.php: -------------------------------------------------------------------------------- 1 | $render->tag( 15 | as: match ($level) { 16 | 1 => 'h1', 17 | 2 => 'h2', 18 | 6 => 'h6', 19 | }, 20 | class: [ 21 | 'font-extrabold text-violet-950 leading-tight tracking-tight', 22 | match ($level) { 23 | // Due to line-height the first heading on a page doesn't have 24 | // consistent spacing on top and sides. We will just "pull" it 25 | // up a few pixels. 26 | 1 => 'text-4xl -mt-3', 27 | 2 => 'text-2xl my-6', 28 | 6 => 'bg-red-500', 29 | }, 30 | ], 31 | attributes: [ 32 | 'id' => $link->destinationWithoutFragmentHash(), 33 | ], 34 | slot: $render->tag( 35 | as: 'a', 36 | class: 'relative group hover:underline', 37 | attributes: [ 38 | 'href' => $link->destination, 39 | ], 40 | after: $render->tag( 41 | as: 'span', 42 | class: 'hidden items-center group-hover:flex leading-none absolute w-5 h-full -right-7 top-0', 43 | attributes: [ 44 | 'aria-hidden' => 'true', 45 | ], 46 | after: $render->component('icon.link'), 47 | ), 48 | ), 49 | ); 50 | -------------------------------------------------------------------------------- /resources/components/unordered-list.php: -------------------------------------------------------------------------------- 1 | $render->tag( 12 | as: 'ul', 13 | class: 'list-disc pl-5 my-3 space-y-2', 14 | ); 15 | -------------------------------------------------------------------------------- /resources/components/warning.php: -------------------------------------------------------------------------------- 1 | $render->tag( 14 | as: 'div', 15 | class: 'bg-red-50/50 my-6 first:mt-0 last:mb-0 p-6 rounded border border-red-100 text-red-950 relative', 16 | attributes: [ 17 | 'aria-role' => 'note', 18 | ], 19 | before: $render->tag( 20 | as: 'strong', 21 | class: 'absolute inline-block right-3 -top-3 rounded bg-red-200 leading-none py-1 px-2 text-sm font-semibold font-mono uppercase', 22 | before: $translator->get('ui.warning.badge'), 23 | ), 24 | // We wrap this in a `div` to ensure that the first / last element 25 | // margin changes are not impacted by the "caution" badge we 26 | // append. 27 | slot: $render->tag('div'), 28 | ); 29 | -------------------------------------------------------------------------------- /resources/footer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /resources/header.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 | PHP 17 | 18 | 19 | 20 |
21 |
22 |
23 | Downloads 24 |
25 |
26 | Get Involved 27 |
28 |
29 | Help 30 |
31 | 34 |
35 |
36 |
37 |
38 | -------------------------------------------------------------------------------- /resources/replacements/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /resources/script.js: -------------------------------------------------------------------------------- 1 | import { docsearch } from "meilisearch-docsearch"; 2 | import "meilisearch-docsearch/css"; 3 | import { computePosition, flip, offset, shift } from "@floating-ui/dom"; 4 | 5 | docsearch({ 6 | container: "#docsearch", 7 | host: import.meta.env.VITE_MEILISEARCH_HOST_URL, 8 | apiKey: import.meta.env.VITE_MEILISEARCH_API_KEY, 9 | indexUid: "docs", 10 | hotKeys: '/' 11 | }) 12 | 13 | const updateTooltip = (tooltip, button) => computePosition(button, tooltip, { 14 | placement: 'top', 15 | middleware: [ 16 | flip(), 17 | shift({ padding: 6 }), 18 | offset(6) 19 | ], 20 | }).then(({x, y}) => { 21 | Object.assign(tooltip.style, { 22 | left: `${x}px`, 23 | top: `${y}px`, 24 | }); 25 | }); 26 | 27 | const showTooltip = (tooltip, button) => { 28 | tooltip.style['transition-delay'] = '200ms' 29 | tooltip.style.display = 'block' 30 | updateTooltip(tooltip, button); 31 | tooltip.classList.add('active') 32 | hoverOutTimeouts 33 | .filter((v) => v.button === button) 34 | .forEach((hoverOutTimeout) => window.clearTimeout(hoverOutTimeout.timeoutId)) 35 | 36 | hoverOutTimeouts.push({ button, timeoutId: setTimeout(() => tooltip.style['transition-delay'] = '0ms', 200)}) 37 | } 38 | 39 | const hideTooltip = (tooltip, button) => { 40 | tooltip.classList.remove('active') 41 | 42 | hoverOutTimeouts.find((v) => v.button === button).timeoutId = window.setTimeout(() => { 43 | tooltip.style.display = 'none' 44 | tooltip.style['transition-delay'] = '0ms' 45 | }, 200) 46 | } 47 | 48 | 49 | const buttons = document.querySelectorAll('button[tooltip-target]') 50 | const hoverOutTimeouts = []; 51 | buttons.forEach((button) => hoverOutTimeouts.push({ button, timeoutId: null })); 52 | 53 | [ 54 | ['mouseenter', showTooltip], 55 | ['mouseleave', hideTooltip], 56 | ['focus', showTooltip], 57 | ['blur', hideTooltip], 58 | ].forEach(([event, listener]) => { 59 | buttons.forEach((button) => { 60 | const tooltip = document.getElementById(button.getAttribute('tooltip-target')) 61 | 62 | button.addEventListener(event, () => listener(tooltip, button)); 63 | }) 64 | }); 65 | 66 | // const prefetched = new Set 67 | 68 | // const prefetch = href => { 69 | // if (prefetched.has(href)) { 70 | // return 71 | // } 72 | 73 | // prefetched.add(href) 74 | 75 | // const link = document.createElement('link') 76 | // link.href = href 77 | // link.rel = 'prefetch' 78 | // link.as = 'document' 79 | // link.fetchPriority = 'low' 80 | 81 | // document.head.appendChild(link) 82 | // } 83 | 84 | // const anchors = () => [ 85 | // ...document.querySelectorAll('a[href][rel~="prefetch"]') 86 | // ].filter(el => { 87 | // try { 88 | // const url = new URL(el.href) 89 | 90 | // return window.location.origin === url.origin && window.location.pathname !== url.pathname 91 | // } catch { 92 | // return false 93 | // } 94 | // }) 95 | 96 | // const observer = new IntersectionObserver((entries, observer) => { 97 | // entries.forEach(entry => { 98 | // if (entry.isIntersecting) { 99 | // observer.unobserve(entry.target) 100 | // prefetch(entry.target.href) 101 | // } 102 | // }) 103 | // }, { threshold: 1.0 }); 104 | 105 | // // requestIdleCallback(() => { 106 | // setTimeout(() => { 107 | // if (window.navigator?.connection?.saveData === true) { 108 | // return; 109 | // } 110 | 111 | // if (/(2|3)g/.test(window.navigator?.connection?.effectiveType ?? '')) { 112 | // return; 113 | // } 114 | 115 | // anchors().forEach(el => observer.observe(el)) 116 | // }) 117 | -------------------------------------------------------------------------------- /resources/style.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #docsearch .docsearch-btn { 6 | color: theme('colors.violet.500'); 7 | height: 33px; 8 | background: theme('colors.violet.25'); 9 | border: 1px solid theme('colors.violet.100'); 10 | } 11 | 12 | #docsearch .docsearch-modal-btn-icon { 13 | height: 20px; 14 | width: 20px; 15 | } 16 | 17 | #docsearch .docsearch-btn-keys { 18 | align-items: center; 19 | content: '/' 20 | } 21 | 22 | #docsearch .docsearch-btn-keys::after { 23 | display: block; 24 | height: 100%; 25 | content: "/"; 26 | } 27 | 28 | #docsearch .docsearch-btn-key { 29 | display: none; 30 | } 31 | 32 | div[role="tooltip"] { 33 | display: none; 34 | opacity: 0; 35 | transform: scale(0.8) translateY(-1.5rem); 36 | width: max-content; 37 | position: absolute; 38 | top: 0; 39 | left: 0; 40 | backdrop-filter: blur(8px); 41 | background: rgba(255, 255, 255, 0.8); 42 | color: theme('colors.slate.800'); 43 | font-family: theme('fontFamily.mono'); 44 | padding: 12px; 45 | box-shadow: theme('boxShadow.xl'); 46 | border-radius: 8px; 47 | font-size: 0.875rem; 48 | transition-property: opacity, transform; 49 | transition-duration: 200ms; 50 | transition-timing-function: ease-out; 51 | } 52 | 53 | div[role="tooltip"].active { 54 | transform: scale(1) translateY(0px); 55 | opacity: 1; 56 | } 57 | -------------------------------------------------------------------------------- /resources/translations/en/ui.php: -------------------------------------------------------------------------------- 1 | 'Documentation by', 7 | 'caution.badge' => 'Caution', 8 | 'editors.by' => 'Edited by', 9 | 'link.external' => 'External link', 10 | 'note.badge' => 'Note', 11 | 'tip.badge' => 'Tip', 12 | 'warning.badge' => 'Warning', 13 | ]; 14 | -------------------------------------------------------------------------------- /src/Contracts/DependsOnIndexes.php: -------------------------------------------------------------------------------- 1 | 13 | */ 14 | public function indexes(): array; 15 | } 16 | -------------------------------------------------------------------------------- /src/Contracts/Generator.php: -------------------------------------------------------------------------------- 1 | reader($path)); 28 | } 29 | 30 | /** 31 | * Make a base XML reader for the given path. 32 | */ 33 | protected function reader(string $path): XMLReader 34 | { 35 | $reader = XMLReader::open($path, 'UTF-8'); 36 | 37 | if ($reader === false) { 38 | throw new RuntimeException('Unable to create XML reader.'); 39 | } 40 | 41 | return $reader; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Manual/Manual.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | protected array $parents = []; 33 | 34 | /** 35 | * The current node's index. 36 | */ 37 | protected int $cursorIndex = -1; 38 | 39 | /** 40 | * The previous node's depth. 41 | */ 42 | protected int $lastDepth = -1; 43 | 44 | /** 45 | * Create a new instance. 46 | */ 47 | public function __construct( 48 | protected XMLReader $xml, 49 | ) { 50 | // 51 | } 52 | 53 | /** 54 | * Read the next node of the manual. 55 | */ 56 | public function read(): ?Node 57 | { 58 | if (! $this->xml->read()) { 59 | $this->xml->close(); 60 | 61 | return null; 62 | } 63 | 64 | $previousSibling = $this->previousSibling(); 65 | 66 | $this->cursorIndex = $this->cursorIndex + 1; 67 | 68 | $this->lastDepth = $this->xml->depth; 69 | 70 | return tap($this->node($this->cursorIndex, $previousSibling), $this->rememberNode(...)); 71 | } 72 | 73 | /** 74 | * The current node instance. 75 | */ 76 | protected function node(int $index, ?string $previousSibling): Node 77 | { 78 | return new Node( 79 | name: $this->xml->name, 80 | type: $this->xml->nodeType, 81 | value: $this->xml->value, 82 | depth: $this->xml->depth, 83 | language: $this->xml->xmlLang, 84 | namespace: $this->xml->namespaceURI, 85 | isSelfClosing: $this->xml->isEmptyElement, 86 | attributes: $this->attributes(), 87 | previousSibling: $previousSibling, 88 | parent: $this->parents[$this->xml->depth - 1] ?? null, 89 | innerContentResolver: function () use ($index) { 90 | if ($index !== $this->cursorIndex) { 91 | throw new RuntimeException('Unable to read the XML contents as the cursor has moved past the current node.'); 92 | } 93 | 94 | return $this->xml->readInnerXml(); 95 | }, 96 | ); 97 | } 98 | 99 | /** 100 | * Remember node for use as a parent. 101 | */ 102 | protected function rememberNode(Node $node): void 103 | { 104 | if ($this->xml->nodeType === XMLReader::ELEMENT) { 105 | $this->parents[$this->xml->depth] = $node; 106 | } 107 | } 108 | 109 | /** 110 | * The current node's previous sibling tag name. 111 | */ 112 | protected function previousSibling(): ?string 113 | { 114 | return $this->lastDepth >= $this->xml->depth 115 | ? $this->parents[$this->xml->depth]->name ?? null 116 | : null; 117 | } 118 | 119 | /** 120 | * The attributes for the current node. 121 | * 122 | * @return array> 123 | */ 124 | protected function attributes(): array 125 | { 126 | $attributes = []; 127 | 128 | if ($this->xml->hasAttributes) { 129 | $this->xml->moveToFirstAttribute(); 130 | 131 | do { 132 | $attributes[$this->xml->namespaceURI ?: static::XMLNS_DOCBOOK][$this->xml->localName] = $this->xml->value; 133 | } while ($this->xml->moveToNextAttribute()); 134 | 135 | $this->xml->moveToElement(); 136 | } 137 | 138 | return $attributes; // @phpstan-ignore return.type 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Manual/Node.php: -------------------------------------------------------------------------------- 1 | > $attributes 18 | */ 19 | public function __construct( 20 | public string $name, 21 | public int $type, 22 | public string $value, 23 | public int $depth, 24 | public string $language, 25 | public string $namespace, 26 | public bool $isSelfClosing, 27 | public array $attributes, 28 | public ?string $previousSibling, 29 | public ?Node $parent, 30 | public Closure $innerContentResolver, 31 | ) { 32 | // 33 | } 34 | 35 | /** 36 | * Retrieve node's ID attribute. 37 | */ 38 | public function id(): string 39 | { 40 | if (! $this->hasId()) { 41 | throw new RuntimeException('Node is missing ID'); 42 | } 43 | 44 | return $this->attributes[Manual::XMLNS_XML]['id']; 45 | } 46 | 47 | /** 48 | * Determine if the node has an ID attribute. 49 | */ 50 | public function hasId(?string $id = null): bool 51 | { 52 | if ($id === null) { 53 | return isset($this->attributes[Manual::XMLNS_XML]['id']); 54 | } else { 55 | return ($this->attributes[Manual::XMLNS_XML]['id'] ?? null) === $id; 56 | } 57 | } 58 | 59 | /** 60 | * Retrieve the node's role attribute. 61 | */ 62 | public function role(): string 63 | { 64 | return $this->attributes[Manual::XMLNS_DOCBOOK]['role']; 65 | } 66 | 67 | /** 68 | * Determine if the node has a role attribute. 69 | */ 70 | public function hasRole(): bool 71 | { 72 | return isset($this->attributes[Manual::XMLNS_DOCBOOK]['role']); 73 | } 74 | 75 | /** 76 | * Retrieve the referenced link. 77 | */ 78 | public function link(): Link 79 | { 80 | if (isset($this->attributes[Manual::XMLNS_DOCBOOK]['linkend'])) { 81 | return Link::internal($this->attributes[Manual::XMLNS_DOCBOOK]['linkend']); 82 | } 83 | 84 | return with($this->attributes[Manual::XMLNS_XLINK]['href'], function (string $href) { 85 | if (str_starts_with('https://www.php.net/', $href)) { 86 | return Link::internal($href); 87 | } 88 | 89 | return Link::external($href); 90 | }); 91 | } 92 | 93 | /** 94 | * Retrieve the given attribute. 95 | */ 96 | public function attribute(string $name): string 97 | { 98 | return $this->attributes[Manual::XMLNS_DOCBOOK][$name]; 99 | } 100 | 101 | /** 102 | * Determine if the node is whitespace. 103 | */ 104 | public function isWhitespace(): bool 105 | { 106 | return in_array($this->type, [ 107 | XMLReader::WHITESPACE, 108 | XMLReader::SIGNIFICANT_WHITESPACE, 109 | ], true); 110 | } 111 | 112 | /** 113 | * Determine if the node is an opening element. 114 | */ 115 | public function isOpeningElement(): bool 116 | { 117 | return $this->type === XMLReader::ELEMENT; 118 | } 119 | 120 | /** 121 | * Determine if the node is a closing element. 122 | */ 123 | public function isClosingElement(): bool 124 | { 125 | return $this->type === XMLReader::END_ELEMENT; 126 | } 127 | 128 | /** 129 | * Determine if the node is text content. 130 | */ 131 | public function isTextContent(): bool 132 | { 133 | return $this->type === XMLReader::TEXT; 134 | } 135 | 136 | /** 137 | * Determine if the node is CDATA. 138 | */ 139 | public function isCData(): bool 140 | { 141 | return $this->type === XMLReader::CDATA; 142 | } 143 | 144 | /** 145 | * Determine if the node is a processing instruction. 146 | */ 147 | public function isProcessingInstruction(): bool 148 | { 149 | return $this->type === XMLReader::PI; 150 | } 151 | 152 | /** 153 | * Determine if the node is a comment. 154 | */ 155 | public function isComment(): bool 156 | { 157 | return $this->type === XMLReader::COMMENT; 158 | } 159 | 160 | /** 161 | * Determine if the node is a doctype. 162 | */ 163 | public function isDoctype(): bool 164 | { 165 | return $this->type === XMLReader::DOC_TYPE; 166 | } 167 | 168 | /** 169 | * Determine if the node has the given previous sibling. 170 | */ 171 | public function hasPreviousSibling(string $sibling): bool 172 | { 173 | return $this->previousSibling === $sibling; 174 | } 175 | 176 | public function expectParent(string $path): Node 177 | { 178 | $parent = $this->parent($path); 179 | 180 | if ($parent === null) { 181 | throw new RuntimeException("Expected parent was not found. From [{$this->lineage()}] looking for parent [{$path}]."); 182 | } 183 | 184 | return $parent; 185 | } 186 | 187 | /** 188 | * Retrieve the given parent. 189 | * 190 | * Supports dot notation, e.g., path: "type.methodparameter" 191 | */ 192 | public function parent(string $path): ?Node 193 | { 194 | $node = $this; 195 | 196 | foreach (explode('.', $path) as $name) { 197 | if ($node->parent?->name === $name) { 198 | $node = $node->parent; 199 | } else { 200 | return null; 201 | } 202 | } 203 | 204 | return $node; 205 | } 206 | 207 | /** 208 | * Determine if the node has the given parent. 209 | * 210 | * Supports dot notation, e.g., path: "type.methodparameter" 211 | */ 212 | public function hasParent(?string $path = null): bool 213 | { 214 | if ($path === null) { 215 | return $this->parent !== null; 216 | } 217 | 218 | return $this->parent($path) !== null; 219 | } 220 | 221 | /** 222 | * Determine if the node has no parent. 223 | */ 224 | public function hasNoParent(): bool 225 | { 226 | return $this->parent === null; 227 | } 228 | 229 | /** 230 | * Retrieve the node's lineage as a dot separated path. 231 | */ 232 | public function lineage(): ?string 233 | { 234 | if ($this->parent === null) { 235 | return null; 236 | } 237 | 238 | $node = $this; 239 | $parents = ''; 240 | 241 | while ($node = $node->parent) { 242 | $parents .= "{$node->name}"; 243 | 244 | if ($node->hasId()) { 245 | $parents .= "[{$node->id()}]"; 246 | } 247 | 248 | $parents .= '.'; 249 | } 250 | 251 | return rtrim($parents, '.'); 252 | } 253 | 254 | /** 255 | * Retrieve the number of ancestors the node has. 256 | */ 257 | public function countAncestors(string $name): int 258 | { 259 | $node = $this; 260 | $count = 0; 261 | 262 | while ($node = $node->parent) { 263 | if ($node->name === $name) { 264 | $count++; 265 | } 266 | } 267 | 268 | return $count; 269 | } 270 | 271 | /** 272 | * Retrieve the given ancestor for the node. 273 | */ 274 | public function ancestor(string $name): ?Node 275 | { 276 | $node = $this; 277 | 278 | while ($node = $node->parent) { 279 | if ($node->name === $name) { 280 | return $node; 281 | } 282 | } 283 | 284 | return null; 285 | } 286 | 287 | /** 288 | * Determine if the node has the given ancestor. 289 | */ 290 | public function hasAncestor(string $name): bool 291 | { 292 | return $this->ancestor($name) !== null; 293 | } 294 | 295 | /** 296 | * Retrieve the list numeration type fo the given node. 297 | */ 298 | public function numeration(): string 299 | { 300 | if ($this->name !== 'orderedlist') { 301 | throw new RuntimeException('Numeration is only accessible on orderedlist nodes.'); 302 | } 303 | 304 | return match ($this->attributes[Manual::XMLNS_DOCBOOK]['numeration'] ?? null) { 305 | 'upperalpha' => 'A', 306 | 'loweralpha' => 'a', 307 | 'upperroman' => 'I', 308 | 'lowerroman' => 'i', 309 | null => '1', 310 | default => throw new RuntimeException('Unknown numeration type.'), 311 | }; 312 | } 313 | 314 | /** 315 | * Determine if the node objects to chunking. 316 | */ 317 | public function objectsToChunking(): bool 318 | { 319 | return isset($this->attributes[Manual::XMLNS_DOCBOOK]['annotations']) 320 | && str_contains($this->attributes[Manual::XMLNS_DOCBOOK]['annotations'], 'chunk:false'); 321 | } 322 | 323 | /** 324 | * Retrieve the node's inner content. 325 | */ 326 | public function innerContent(): string 327 | { 328 | return once($this->innerContentResolver); 329 | } 330 | 331 | /** 332 | * Determine if the node has the given attribute. 333 | */ 334 | public function hasAttribute(string $name): bool 335 | { 336 | foreach ($this->attributes as $namespace => $attributes) { 337 | if (array_key_exists($name, $attributes)) { 338 | return true; 339 | } 340 | } 341 | 342 | return false; 343 | } 344 | 345 | /** 346 | * Determine if the node has any attributes. 347 | */ 348 | public function hasAnyAttributes(): bool 349 | { 350 | return $this->attributes !== []; 351 | } 352 | 353 | /** 354 | * Determine if the node wants to chunk. 355 | */ 356 | public function wantsToChunk(): bool 357 | { 358 | return in_array($this->name, [ 359 | 'book', 360 | 'chapter', 361 | 'legalnotice', 362 | 'preface', 363 | 'sect1', 364 | 'section', 365 | ], true) && $this->hasId() && ! $this->objectsToChunking(); 366 | } 367 | } 368 | -------------------------------------------------------------------------------- /src/Output.php: -------------------------------------------------------------------------------- 1 | lineWritten = false; 20 | 21 | echo str($message) 22 | ->replace([ 23 | '', 24 | '', 25 | '', 26 | '', 27 | '', 28 | '', 29 | '', 30 | '', 31 | '', 32 | '', 33 | ], [ 34 | "\033[1m", // bold 35 | "\033[22m", 36 | "\033[92m", // green 37 | "\033[39m", 38 | "\033[93m", // yellow 39 | "\033[39m", 40 | "\033[94m", // blue 41 | "\033[39m", 42 | "\033[2m", // dim 43 | "\033[22m", 44 | ]); 45 | 46 | return $this; 47 | } 48 | 49 | /** 50 | * Write the given line. 51 | */ 52 | public function line(string $message = ''): Output 53 | { 54 | if (! $this->lineWritten) { 55 | $message = PHP_EOL.$message; 56 | } 57 | 58 | $this->write($message.PHP_EOL); 59 | 60 | $this->lineWritten = true; 61 | 62 | return $this; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Process.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | protected SplObjectStorage $streams; 24 | 25 | /** 26 | * Pending "closers" for each generator. 27 | * 28 | * @var SplObjectStorage> 29 | */ 30 | protected SplObjectStorage $closers; 31 | 32 | /** 33 | * Process the manual against the given generators. 34 | * 35 | * @param iterable $generators 36 | */ 37 | public function handle(Manual $manual, iterable $generators, ?Closure $onTick = null): void 38 | { 39 | $this->streams = new SplObjectStorage; 40 | $this->closers = new SplObjectStorage; 41 | 42 | $onTick ??= fn () => null; 43 | $iteration = 0; 44 | 45 | foreach ($generators as $generator) { 46 | $generator->setUp(); 47 | $this->closers[$generator] = []; 48 | } 49 | 50 | while ($node = $manual->read()) { 51 | if ($node->isDoctype()) { 52 | continue; 53 | } 54 | 55 | $onTick($node, $iteration++); 56 | 57 | foreach ($generators as $generator) { 58 | if ($iteration === 1) { 59 | $this->streams[$generator] = $generator->stream($node); 60 | } 61 | 62 | if ($node->isOpeningElement()) { 63 | $this->handleOpeningElement($generator, $node); 64 | 65 | continue; 66 | } 67 | 68 | if ($node->isClosingElement()) { 69 | $this->handleClosingElement($generator, $node); 70 | 71 | continue; 72 | } 73 | 74 | if ( 75 | $node->isTextContent() || 76 | $node->isCData() || 77 | $node->isProcessingInstruction() 78 | ) { 79 | $this->write($generator, $generator->render($node), $node); 80 | 81 | continue; 82 | } 83 | 84 | if ( 85 | $node->isWhitespace() || 86 | $node->isComment() 87 | ) { 88 | continue; 89 | } 90 | 91 | throw new RuntimeException("Encountered an unhandled node of type [{$node->type}] with the name [{$node->name}]."); 92 | } 93 | } 94 | 95 | foreach ($generators as $generator) { 96 | $this->writePendingClosers($generator); 97 | 98 | $generator->tearDown(); 99 | 100 | $this->streams[$generator]->close(); 101 | } 102 | 103 | $this->streams = new SplObjectStorage; 104 | $this->closers = new SplObjectStorage; 105 | } 106 | 107 | /** 108 | * Handle an opening element. 109 | */ 110 | protected function handleOpeningElement(Generator $generator, Node $node): void 111 | { 112 | if ($generator->shouldChunk($node)) { 113 | $this->writePendingClosers($generator); 114 | 115 | $this->streams[$generator]->close(); 116 | 117 | $this->streams[$generator] = $generator->stream($node); 118 | } 119 | 120 | $this->write($generator, $generator->render($node), $node); 121 | } 122 | 123 | /** 124 | * Handle an closing element. 125 | */ 126 | protected function handleClosingElement(Generator $generator, Node $node): void 127 | { 128 | if ($this->matchesNextPendingCloser($generator, $node)) { 129 | $this->writeNextCloser($generator); 130 | } 131 | } 132 | 133 | /** 134 | * Determine if the node matches the next pending closer. 135 | */ 136 | protected function matchesNextPendingCloser(Generator $generator, Node $node): bool 137 | { 138 | if ($this->closers[$generator] === []) { 139 | return false; 140 | } 141 | 142 | return with($this->closers[$generator][array_key_last($this->closers[$generator])][1], function (Node $openingNode) use ($node) { 143 | return $openingNode->name === $node->name && $openingNode->depth === $node->depth; 144 | }); 145 | } 146 | 147 | /** 148 | * Write all pending closers. 149 | */ 150 | protected function writePendingClosers(Generator $generator): void 151 | { 152 | while ($this->closers[$generator] !== []) { 153 | $this->writeNextCloser($generator); 154 | } 155 | } 156 | 157 | /** 158 | * Write the next pending closer. 159 | */ 160 | protected function writeNextCloser(Generator $generator): void 161 | { 162 | if ($this->closers[$generator] !== []) { 163 | $closers = $this->closers[$generator]; 164 | 165 | $this->write($generator, ...array_pop($closers)); 166 | 167 | $this->closers[$generator] = $closers; 168 | } 169 | } 170 | 171 | /** 172 | * Write the node's content for the generator. 173 | */ 174 | protected function write(Generator $generator, string|Slotable $content, Node $node): void 175 | { 176 | if ($content === '') { 177 | return; 178 | } 179 | 180 | if (is_string($content)) { 181 | $this->streams[$generator]->write($content); 182 | 183 | return; 184 | } 185 | 186 | $this->write($generator, $content->before(), $node); 187 | 188 | if ($node->isSelfClosing) { 189 | $this->write($generator, $content->after(), $node); 190 | } else { 191 | $this->closers[$generator] = [ 192 | ...$this->closers[$generator], 193 | [$content->after(), $node], 194 | ]; 195 | } 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Render/Factory.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected array $resolverCache = []; 21 | 22 | /** 23 | * Create a new instance. 24 | */ 25 | public function __construct( 26 | protected Configuration $config, 27 | protected Highlighter $highlighter, 28 | protected Replacer $replacer, 29 | protected Container $container, 30 | ) { 31 | // 32 | } 33 | 34 | /** 35 | * Make a HTML string. 36 | */ 37 | public function html(string $content): HtmlString 38 | { 39 | return new HtmlString($content); 40 | } 41 | 42 | /** 43 | * Make a HTML tag. 44 | * 45 | * @param array> $attributes 46 | * @param list|string $class 47 | */ 48 | public function tag( 49 | string $as, 50 | array $attributes = [], 51 | array|string $class = [], 52 | string|Stringable $before = '', 53 | string|Stringable $after = '', 54 | ?Slotable $slot = null, 55 | ): HtmlTag { 56 | return new HtmlTag($as, $attributes, $class, $before, $after, $slot); 57 | } 58 | 59 | /** 60 | * Make a code snippet. 61 | */ 62 | public function codeSnippet(string $snippet, string $language): string 63 | { 64 | return array_reduce([ 65 | $this->replacer->handle(...), 66 | $this->highlighter->handle(...), 67 | ], fn ($snippet, $f) => $f($snippet, $language), $snippet); 68 | } 69 | 70 | /** 71 | * Make inline text. 72 | */ 73 | public function inlineText(string $before = '', string $after = ''): Wrapper 74 | { 75 | return $this->wrapper( 76 | before: ' '.ltrim($before, ' '), 77 | after: $after, 78 | ); 79 | } 80 | 81 | /** 82 | * Make a wrapper. 83 | */ 84 | public function wrapper(string|Stringable $before = '', string|Stringable $after = '', ?Slotable $slot = null): Wrapper 85 | { 86 | return new Wrapper($before, $after, $slot); 87 | } 88 | 89 | /** 90 | * Make a component. 91 | * 92 | * @param array $data 93 | */ 94 | public function component(string|Closure $component, array $data = []): Slotable 95 | { 96 | return with($this->resolve($component, $data), fn (Slotable|string $component) => $component instanceof Slotable 97 | ? $component 98 | : new HtmlString($component)); 99 | } 100 | 101 | public function export(mixed $value): string 102 | { 103 | return var_export($value, true); 104 | } 105 | 106 | /** 107 | * Resolve the component. 108 | * 109 | * @param array $data 110 | */ 111 | protected function resolve(string|Closure $component, array $data): Slotable|string 112 | { 113 | return $this->container->call( 114 | $component instanceof Closure ? $component : $this->resolver($component), 115 | $data, 116 | ); 117 | } 118 | 119 | /** 120 | * The component resolver. 121 | */ 122 | protected function resolver(string $path): Closure 123 | { 124 | return $this->resolverCache[$path] ??= (static fn (string $__path) => require_once $__path)( 125 | "{$this->config->get('component_directory')}/{$path}.php" 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Render/Highlighter.php: -------------------------------------------------------------------------------- 1 | 20 | */ 21 | protected Collection $filesCache; 22 | 23 | /** 24 | * Create a new instance. 25 | */ 26 | public function __construct( 27 | protected Shiki $shiki, 28 | protected Configuration $config, 29 | protected Finder $finder, 30 | ) { 31 | // 32 | } 33 | 34 | /** 35 | * Highlight the given code snippet. 36 | */ 37 | public function handle(string $code, string $language): string 38 | { 39 | if ($this->files()->contains($path = $this->path($code, $language))) { 40 | return file_get_contents($path); 41 | } 42 | 43 | return tap($this->shiki->highlightCode($code, match ($language) { 44 | 'php' => 'blade', 45 | 'apache-conf' => 'apache', 46 | 'nginx-conf' => 'nginx', 47 | default => $language, 48 | }), fn ($content) => file_put_contents($path, $content)); 49 | } 50 | 51 | /** 52 | * The cache path for the given code snippet. 53 | */ 54 | protected function path(string $code, string $language): string 55 | { 56 | return "{$this->config->get('cache_directory')}/{$this->hash($code, $language)}-highlight.{$language}.html"; 57 | } 58 | 59 | /** 60 | * The hash for the given code snippet. 61 | */ 62 | protected function hash(string $code, string $language): string 63 | { 64 | return hash('xxh128', "{$language}\n{$code}"); 65 | } 66 | 67 | /** 68 | * The cached highlighted files. 69 | * 70 | * @return Collection 71 | */ 72 | protected function files(): Collection 73 | { 74 | return $this->filesCache ??= collect($this->finder 75 | ->in($this->config->get('cache_directory')) 76 | ->files() 77 | ->depth(0) 78 | ->name('*-highlight.*.html') 79 | ->getIterator())->keys(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Render/HtmlString.php: -------------------------------------------------------------------------------- 1 | content; 27 | } 28 | 29 | /** 30 | * The content after the main content. 31 | */ 32 | public function after(): string 33 | { 34 | return ''; 35 | } 36 | 37 | /** 38 | * Convert to a string. 39 | */ 40 | public function toString(): string 41 | { 42 | return $this->before().$this->after(); 43 | } 44 | 45 | /** 46 | * Convert to a string. 47 | */ 48 | public function __toString(): string 49 | { 50 | return $this->toString(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Render/HtmlTag.php: -------------------------------------------------------------------------------- 1 | > $attributes 17 | * @param list|string $class 18 | */ 19 | public function __construct( 20 | protected string $as, 21 | protected array $attributes, 22 | protected array|string $class, 23 | protected string|Stringable $before, 24 | protected string|Stringable $after, 25 | protected ?Slotable $slot, 26 | ) { 27 | // 28 | } 29 | 30 | /** 31 | * Retrieve the "before" content. 32 | */ 33 | public function before(): string 34 | { 35 | if ($this->isVoidTag()) { 36 | return "<{$this->as}{$this->attributeList()}>"; 37 | } 38 | 39 | return "<{$this->as}{$this->attributeList()}>{$this->before}{$this->slot?->before()}"; 40 | } 41 | 42 | /** 43 | * Retrieve the "after" content. 44 | */ 45 | public function after(): string 46 | { 47 | if ($this->isVoidTag()) { 48 | return ''; 49 | } 50 | 51 | return "{$this->slot?->after()}{$this->after}as}>"; 52 | } 53 | 54 | /** 55 | * Retrieve the tags attribute list. 56 | */ 57 | protected function attributeList(): string 58 | { 59 | return with($this->attributes(), fn (array $attributes) => $attributes === [] 60 | ? '' 61 | : ' '.implode(' ', $attributes)); 62 | } 63 | 64 | /** 65 | * Retrieve the tags attributes. 66 | * 67 | * @return list 68 | */ 69 | protected function attributes(): array 70 | { 71 | $attributes = array_merge($this->attributes, [ 72 | 'class' => $this->class ?: false, 73 | ]); 74 | 75 | $attributes = array_map(function (string|array|bool $value, string $key) { 76 | if ($value === false) { 77 | return false; 78 | } 79 | 80 | $key = trim($key); 81 | 82 | if ($value === true) { 83 | return $key; 84 | } 85 | 86 | if (! is_array($value)) { 87 | $value = explode(' ', $value); 88 | } 89 | 90 | $value = array_map(trim(...), $value); 91 | 92 | return $key.'="'.implode(' ', $value).'"'; 93 | }, $attributes, array_keys($attributes)); 94 | 95 | $attributes = array_filter($attributes, fn (string|false $value) => $value !== false); 96 | 97 | return array_values($attributes); 98 | } 99 | 100 | /** 101 | * Attach the given attributes. 102 | * 103 | * @param array> $attributes 104 | */ 105 | public function withAttributes(array $attributes): HtmlTag 106 | { 107 | return new HtmlTag( 108 | as: $this->as, 109 | attributes: array_merge_recursive($this->attributes, $attributes), 110 | class: $this->class, 111 | before: $this->before, 112 | after: $this->after, 113 | slot: $this->slot, 114 | ); 115 | } 116 | 117 | /** 118 | * Wrap the given slot. 119 | */ 120 | public function wrapSlot(Slotable $slot): HtmlTag 121 | { 122 | return new HtmlTag( 123 | as: $this->as, 124 | attributes: $this->attributes, 125 | class: $this->class, 126 | before: $this->before, 127 | after: $this->after, 128 | slot: $slot, 129 | ); 130 | } 131 | 132 | /** 133 | * Convert to a string. 134 | */ 135 | public function toString(): string 136 | { 137 | if ($this->slot !== null) { 138 | throw new RuntimeException('Unable to render a tag with a content wrapper.'); 139 | } 140 | 141 | return $this->before().$this->after(); 142 | } 143 | 144 | /** 145 | * Convert to a string. 146 | */ 147 | public function __toString(): string 148 | { 149 | return $this->toString(); 150 | } 151 | 152 | /** 153 | * Determine if the tag is considered a void tag that does not need closing. 154 | */ 155 | protected function isVoidTag(): bool 156 | { 157 | return in_array($this->as, [ 158 | 'area', 159 | 'base', 160 | 'br', 161 | 'col', 162 | 'embed', 163 | 'hr', 164 | 'img', 165 | 'input', 166 | 'link', 167 | 'meta', 168 | 'source', 169 | 'track', 170 | 'wbr', 171 | ], true); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/Render/Replacer.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | protected Collection $filesCache; 21 | 22 | /** 23 | * Create a new instance. 24 | */ 25 | public function __construct( 26 | protected Configuration $config, 27 | protected Finder $finder, 28 | ) { 29 | // 30 | } 31 | 32 | /** 33 | * Replace the given code snippet. 34 | */ 35 | public function handle(string $code, string $language): string 36 | { 37 | if ($this->files()->doesntContain($path = $this->originalPath($code, $language))) { 38 | file_put_contents($path, $code); 39 | } 40 | 41 | if ($this->files()->contains($path = $this->replacementPath($code, $language))) { 42 | return file_get_contents($path); 43 | } 44 | 45 | return $code; 46 | } 47 | 48 | /** 49 | * The cache path for the original code snippet. 50 | */ 51 | protected function originalPath(string $code, string $language): string 52 | { 53 | return "{$this->config->get('cache_directory')}/{$this->hash($code, $language)}-original.{$language}"; 54 | } 55 | 56 | /** 57 | * The cache path for replacement code snippet. 58 | */ 59 | protected function replacementPath(string $code, string $language): string 60 | { 61 | // TODO. these should not be in the cache path and instead in a commited repository. 62 | // Maybe we should commit all code snippets to track what is modified? 63 | return "{$this->config->get('cache_directory')}/{$this->hash($code, $language)}-replacement.{$language}"; 64 | } 65 | 66 | /** 67 | * The hash for the given code snippet. 68 | */ 69 | protected function hash(string $code, string $language): string 70 | { 71 | return hash('xxh128', "{$language}\n{$code}"); 72 | } 73 | 74 | /** 75 | * The cached replacement and original files. 76 | * 77 | * @return Collection 78 | */ 79 | protected function files(): Collection 80 | { 81 | return $this->filesCache ??= collect($this->finder 82 | ->in($this->config->get('cache_directory')) 83 | ->files() 84 | ->depth(0) 85 | ->name(['*-replacement.*', '*-original.*']) 86 | ->getIterator())->keys(); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Render/Wrapper.php: -------------------------------------------------------------------------------- 1 | before.$this->slot?->before(); 30 | } 31 | 32 | /** 33 | * Retrieve the "after" content. 34 | */ 35 | public function after(): string 36 | { 37 | return $this->slot?->after().$this->after; 38 | } 39 | 40 | /** 41 | * Convert to a string. 42 | */ 43 | public function toString(): string 44 | { 45 | if ($this->slot !== null) { 46 | throw new RuntimeException('Unable to render a wrapper with a content wrapper.'); 47 | } 48 | 49 | return $this->before().$this->after(); 50 | } 51 | 52 | /** 53 | * Convert to a string. 54 | */ 55 | public function __toString(): string 56 | { 57 | return $this->toString(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Stream/FileStreamFactory.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | protected array $buffer = []; 20 | 21 | /** 22 | * Make a new file stream. 23 | */ 24 | public function make(string $path, int $chunk): Stream 25 | { 26 | return new Stream( 27 | $path, 28 | function (string $path) use ($chunk) { 29 | if (! file_exists(dirname($path))) { 30 | mkdir(dirname($path), recursive: true); 31 | } 32 | 33 | return with(fopen($path, 'w'), fn ($file) => [ 34 | function (string $content) use ($file, $chunk) { 35 | $this->buffer[] = $content; 36 | 37 | if (count($this->buffer) >= $chunk) { 38 | $this->flushBuffer($file); 39 | } 40 | }, 41 | function () use ($file) { 42 | $this->flushBuffer($file); 43 | 44 | fclose($file); 45 | }, 46 | ]); 47 | }, 48 | ); 49 | } 50 | 51 | /** 52 | * Flush the buffer to the stream. 53 | * 54 | * @param resource $file 55 | */ 56 | protected function flushBuffer($file): void 57 | { 58 | if ($this->buffer === []) { 59 | return; 60 | } 61 | 62 | fwrite($file, implode('', $this->buffer)); 63 | 64 | $this->buffer = []; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Stream/State.php: -------------------------------------------------------------------------------- 1 | state === State::Closed) { 43 | throw new RuntimeException('Unable to write. Stream is closed.'); 44 | } 45 | 46 | if ($this->state === State::Unopened) { 47 | [$this->write, $this->close] = ($this->open)($this->path); 48 | 49 | $this->state = State::Open; 50 | } 51 | 52 | assert($this->write !== null); 53 | 54 | ($this->write)($content); 55 | 56 | return $this; 57 | } 58 | 59 | /** 60 | * Close the stream. 61 | */ 62 | public function close(): void 63 | { 64 | if ($this->state === State::Closed) { 65 | throw new RuntimeException('Stream has already been closed.'); 66 | } 67 | 68 | if ($this->state === State::Open) { 69 | assert($this->close !== null); 70 | 71 | ($this->close)(); 72 | 73 | $this->write = $this->close = null; 74 | } 75 | 76 | $this->state = State::Closed; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Support/Link.php: -------------------------------------------------------------------------------- 1 | destination, '#'); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Support/Translator.php: -------------------------------------------------------------------------------- 1 | $replace 13 | */ 14 | public function get($key, array $replace = [], $locale = null, $fallback = true): string 15 | { 16 | $value = parent::get(...func_get_args()); 17 | 18 | assert(is_string($value)); 19 | 20 | return $value; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Website/EmptyChunkIndex.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | protected ?Collection $idsCache; 29 | 30 | /** 31 | * Indicates that the current chunk is empty. 32 | */ 33 | protected bool $isEmpty = true; 34 | 35 | /** 36 | * The current chunk. 37 | */ 38 | protected ?Node $chunk = null; 39 | 40 | /** 41 | * Create a new instance. 42 | */ 43 | public function __construct( 44 | protected FileStreamFactory $streamFactory, 45 | protected Configuration $config, 46 | protected Factory $render, 47 | ) { 48 | $this->stream = $this->streamFactory->make( 49 | "{$this->config->get('index_directory')}/website/{$this->config->get('language')}/empty_pages.php", 50 | 1000, 51 | ); 52 | } 53 | 54 | /** 55 | * Set up. 56 | */ 57 | public function setUp(): void 58 | { 59 | $this->stream->write(<<<'PHP' 60 | stream; 75 | } 76 | 77 | /** 78 | * Determine if the generator should chunk. 79 | */ 80 | public function shouldChunk(Node $node): bool 81 | { 82 | return false; 83 | } 84 | 85 | /** 86 | * Render the given node. 87 | */ 88 | public function render(Node $node): string|Slotable 89 | { 90 | if ($node->wantsToChunk()) { 91 | return with($this->emptyChunk(), function (?Node $emptyChunk) use ($node) { 92 | $this->chunk = $node; 93 | $this->isEmpty = true; 94 | 95 | return $emptyChunk !== null 96 | ? " {$this->render->export($emptyChunk->id())},\n" 97 | : ''; 98 | }); 99 | } 100 | 101 | if (! in_array($node->name, ['info', 'title', '#text'], true)) { 102 | $this->isEmpty = false; 103 | } 104 | 105 | return ''; 106 | } 107 | 108 | /** 109 | * Tear down. 110 | */ 111 | public function tearDown(): void 112 | { 113 | $this->stream->write('];'); 114 | } 115 | 116 | /** 117 | * Retrieve the empty chunk IDs. 118 | * 119 | * @return Collection 120 | */ 121 | public function ids(): Collection 122 | { 123 | return $this->idsCache ??= collect(require $this->stream->path); // @phpstan-ignore argument.templateType, argument.templateType 124 | } 125 | 126 | /** 127 | * Retrieve the current empty chunk. 128 | */ 129 | protected function emptyChunk(): ?Node 130 | { 131 | return $this->isEmpty 132 | ? $this->chunk 133 | : null; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/Website/FunctionIndex.php: -------------------------------------------------------------------------------- 1 | > 29 | */ 30 | protected ?Collection $allCache; 31 | 32 | /** 33 | * The current parameter number. 34 | */ 35 | protected int $paramNumber = 1; 36 | 37 | /** 38 | * The function description. 39 | */ 40 | protected ?string $description = null; 41 | 42 | /** 43 | * Create a new instance. 44 | */ 45 | public function __construct( 46 | protected Factory $render, 47 | protected FileStreamFactory $streamFactory, 48 | protected Configuration $config, 49 | ) { 50 | $this->stream = $this->streamFactory->make( 51 | "{$this->config->get('index_directory')}/website/{$this->config->get('language')}/functions.php", 52 | 1000, 53 | ); 54 | } 55 | 56 | /** 57 | * Set up. 58 | */ 59 | public function setUp(): void 60 | { 61 | $this->stream->write(<<<'PHP' 62 | stream; 80 | } 81 | 82 | /** 83 | * Determine if the generator should chunk. 84 | */ 85 | public function shouldChunk(Node $node): bool 86 | { 87 | return false; 88 | } 89 | 90 | /** 91 | * Render the given node. 92 | */ 93 | public function render(Node $node): string|Slotable 94 | { 95 | if ($node->name === 'refpurpose') { 96 | // Str::squish? 97 | $this->description = $node->innerContent(); 98 | } 99 | 100 | if ($node->name === 'methodsynopsis' && $node->parent('refsect1')) { 101 | // TODO think we can reset like we do in the image index. 102 | $wrapper = $this->render->wrapper( 103 | before: " new Method(\n description:".$this->render->export($this->description).',', 104 | after: new class(fn () => ($this->paramNumber = 1)) implements Stringable 105 | { 106 | public function __construct(protected Closure $callback) 107 | { 108 | // 109 | } 110 | 111 | public function __toString(): string 112 | { 113 | ($this->callback)(); 114 | 115 | return "\n ),\n\n"; 116 | } 117 | }, 118 | ); 119 | 120 | $this->description = null; 121 | 122 | return $wrapper; 123 | } 124 | 125 | if ($node->name === 'methodparam' && $node->parent('methodsynopsis.refsect1')) { 126 | return $this->render->wrapper( 127 | before: "\n p".($this->paramNumber++).': new Parameter([', 128 | after: '),', 129 | ); 130 | } 131 | 132 | if ( 133 | $node->name === 'type' && 134 | $node->parent('methodsynopsis.refsect1') && 135 | $node->hasAttribute('class') && 136 | $node->attribute('class') === 'union' 137 | ) { 138 | return $this->render->wrapper( 139 | before: "\n returnTypes: [", 140 | after: '],', 141 | ); 142 | } 143 | 144 | if ( 145 | $node->name === 'type' && 146 | $node->parent('methodsynopsis.refsect1') 147 | ) { 148 | return $this->render->wrapper( 149 | before: "\n returnTypes: [", 150 | after: '],', 151 | ); 152 | } 153 | 154 | if ( 155 | $node->name === 'type' && 156 | $node->parent('methodparam.methodsynopsis.refsect1') && 157 | $node->hasAttribute('class') && 158 | $node->attribute('class') === 'union' 159 | ) { 160 | return $this->render->wrapper( 161 | before: '[', 162 | after: '],', 163 | ); 164 | } 165 | 166 | if ($node->name === '#text') { 167 | // return type 168 | if ($node->parent('type.methodsynopsis.refsect1') || $node->parent('type.type.methodsynopsis.refsect1')) { 169 | return "'{$node->value}',"; 170 | } 171 | 172 | // method name 173 | if ($node->parent('methodname.methodsynopsis.refsect1')) { 174 | return "\n name:".$this->render->export($node->value).','; 175 | } 176 | 177 | // param type 178 | if ($node->parent('type.methodparam.methodsynopsis.refsect1') || $node->parent('type.type.methodparam.methodsynopsis.refsect1')) { 179 | return $this->render->export($node->value).','; 180 | } 181 | 182 | // param name 183 | if ($node->parent('parameter.methodparam.methodsynopsis.refsect1')) { 184 | return '],'.$this->render->export($node->value); 185 | } 186 | } 187 | 188 | return ''; 189 | } 190 | 191 | /** 192 | * Tear down. 193 | */ 194 | public function tearDown(): void 195 | { 196 | $this->stream->write('];'); 197 | } 198 | 199 | /** 200 | * Retrieve all functions from the index. 201 | * 202 | * @return Collection> 203 | */ 204 | public function all(): Collection 205 | { 206 | return $this->allCache ??= collect(require $this->stream->path) // @phpstan-ignore argument.templateType, argument.templateType, assign.propertyType, return.type 207 | ->reduce(function ($carry, $function) { 208 | $carry[$function->name] = collect([ 209 | ...($carry[$function->name] ?? []), 210 | $function, 211 | ]); 212 | 213 | return $carry; 214 | }, collect([])); 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /src/Website/Generator.php: -------------------------------------------------------------------------------- 1 | config->get('build_directory')}/{$node->id()}.html", 68 | fn (string $path) => with($this->streamFactory->make($path, 1000), fn (Stream $stream) => [ 69 | $stream->write(file_get_contents('resources/header.html')) 70 | ->write($this->render->component('main')->before()) 71 | ->write(...), 72 | fn () => $stream->write($this->render->component('main')->after()) 73 | ->write($this->render->component('menu', [ 74 | 'active' => $this->titleIndex->find($node->id()), 75 | 'items' => $this->titleIndex->heirachy(), 76 | 'empty' => $this->titleIndex->findMany( 77 | $this->emptyChunkIndex->ids(), 78 | ), 79 | ])->toString()) 80 | ->write(file_get_contents('resources/footer.html')) 81 | ->close(), 82 | ]), 83 | ); 84 | } 85 | 86 | /** 87 | * Determine if the generator should chunk. 88 | */ 89 | public function shouldChunk(Node $node): bool 90 | { 91 | return $node->wantsToChunk(); 92 | } 93 | 94 | /** 95 | * Render the given node. 96 | */ 97 | public function render(Node $node): Slotable|string 98 | { 99 | return with(match ($node->name) { 100 | '#cdata-section' => $this->renderCData($node), 101 | '#text' => $this->renderText($node), 102 | 'abbrev' => $this->renderAbbrev($node), 103 | 'abstract' => $this->renderAbstract($node), 104 | 'acronym' => $this->renderAcronym($node), 105 | 'alt' => $this->renderAlt($node), 106 | 'author' => $this->renderAuthor($node), 107 | 'authorgroup' => $this->renderAuthorGroup($node), 108 | 'book' => $this->renderBook($node), 109 | 'caption' => $this->renderCaption($node), 110 | 'caution' => $this->renderCaution($node), 111 | 'chapter' => $this->renderChapter($node), 112 | 'classname' => $this->renderClassName($node), 113 | 'code' => $this->renderCode($node), 114 | 'command' => $this->renderCommand($node), 115 | 'computeroutput' => $this->renderComputerOutput($node), 116 | 'constant' => $this->renderConstant($node), 117 | 'copyright' => $this->renderCopyright($node), 118 | 'dbtimestamp' => $this->renderDbTimestamp($node), 119 | 'editor' => $this->renderEditor($node), 120 | 'emphasis' => $this->renderEmphasis($node), 121 | 'entry' => $this->renderEntry($node), 122 | 'envar' => $this->renderEnVar($node), 123 | 'example' => $this->renderExample($node), 124 | 'filename' => $this->renderFilename($node), 125 | 'firstname' => $this->renderFirstName($node), 126 | 'function' => $this->renderFunction($node), 127 | 'holder' => $this->renderHolder($node), 128 | 'imagedata' => $this->renderImageData($node), 129 | 'imageobject' => $this->renderImageObject($node), 130 | 'info' => $this->renderInfo($node), 131 | 'informalexample' => $this->renderInformalExample($node), 132 | 'informaltable' => $this->renderInformalTable($node), 133 | 'interfacename' => $this->renderInterfaceName($node), 134 | 'itemizedlist' => $this->renderItemizedList($node), 135 | 'legalnotice' => $this->renderLegalNotice($node), 136 | 'link' => $this->renderLink($node), 137 | 'listitem' => $this->renderListItem($node), 138 | 'literal' => $this->renderLiteral($node), 139 | 'literallayout' => $this->renderLiteralLayout($node), 140 | 'mediaobject' => $this->renderMediaObject($node), 141 | 'member' => $this->renderMember($node), 142 | 'methodname' => $this->renderMethodName($node), 143 | 'modifier' => $this->renderModifier($node), 144 | 'note' => $this->renderNote($node), 145 | 'option' => $this->renderOption($node), 146 | 'optional' => $this->renderOptional($node), 147 | 'orderedlist' => $this->renderOrderedList($node), 148 | 'othercredit' => $this->renderOtherCredit($node), 149 | 'othername' => $this->renderOtherName($node), 150 | 'para' => $this->renderPara($node), 151 | 'parameter' => $this->renderParameter($node), 152 | 'personname' => $this->renderPersonName($node), 153 | 'phpdoc' => $this->renderPhpDoc($node), 154 | 'preface' => $this->renderPreface($node), 155 | 'procedure' => $this->renderProcedure($node), 156 | 'productname' => $this->renderProductName($node), 157 | 'programlisting' => $this->renderProgramListing($node), 158 | 'pubdate' => $this->renderPubDate($node), 159 | 'replaceable' => $this->renderReplaceable($node), 160 | 'row' => $this->renderRow($node), 161 | 'screen' => $this->renderScreen($node), 162 | 'sect1' => $this->renderSect1($node), 163 | 'sect2' => $this->renderSect2($node), 164 | 'sect3' => $this->renderSect3($node), 165 | 'sect4' => $this->renderSect4($node), 166 | 'section' => $this->renderSection($node), 167 | 'set' => $this->renderSet($node), 168 | 'simpara' => $this->renderSimPara($node), 169 | 'simplelist' => $this->renderSimpleList($node), 170 | 'step' => $this->renderStep($node), 171 | 'surname' => $this->renderSurname($node), 172 | 'synopsis' => $this->renderSynopsis($node), 173 | 'systemitem' => $this->renderSystemItem($node), 174 | 'table' => $this->renderTable($node), 175 | 'tbody' => $this->renderTBody($node), 176 | 'term' => $this->renderTerm($node), 177 | 'tgroup' => $this->renderTGroup($node), 178 | 'thead' => $this->renderTHead($node), 179 | 'tip' => $this->renderTip($node), 180 | 'title' => $this->renderTitle($node), 181 | 'titleabbrev' => $this->renderTitleAbbrev($node), 182 | 'type' => $this->renderType($node), 183 | 'userinput' => $this->renderUserInput($node), 184 | 'variablelist' => $this->renderVariableList($node), 185 | 'varlistentry' => $this->renderVarListEntry($node), 186 | 'varname' => $this->renderVarName($node), 187 | 'warning' => $this->renderWarning($node), 188 | 'xref' => $this->renderXref($node), 189 | 'year' => $this->renderYear($node), 190 | default => tap('', fn () => dd('Unknown node', $node->name, $node->lineage())), 191 | }, fn (string|Slotable $content) => $this->config->get('debug') 192 | ? $this->withDebuggingInfo($node, $content) 193 | : $content); 194 | } 195 | 196 | /** 197 | * Tear down. 198 | */ 199 | public function tearDown(): void 200 | { 201 | $this->output->line(<<<'BASH' 202 | Building assets. 203 | BASH); 204 | 205 | system(<<<'BASH' 206 | npm run build 207 | npx tailwindcss -i ./resources/style.css -o ./build/output/style.css 208 | cp ./resources/script.js ./build/output/script.js 209 | BASH); 210 | } 211 | 212 | /** 213 | * Retrieve the generator's indexes. 214 | * 215 | * @return list 216 | */ 217 | public function indexes(): array 218 | { 219 | return [ 220 | $this->titleIndex, 221 | $this->imageIndex, 222 | $this->functionIndex, 223 | $this->emptyChunkIndex, 224 | ]; 225 | } 226 | 227 | /** 228 | * Render the CDATA node. 229 | */ 230 | protected function renderCData(Node $node): Slotable|string 231 | { 232 | $content = preg_replace('/^\\n/', '', $node->value); 233 | 234 | /** 235 | * A literal listing of all or part of a program. 236 | * 237 | * @see self::renderProgramListing() 238 | */ 239 | if ($programlisting = $node->parent('programlisting')) { 240 | if ($programlisting->hasRole()) { 241 | return $this->render->codeSnippet($content, $programlisting->role()); 242 | } 243 | 244 | return $content; 245 | } 246 | 247 | /** 248 | * Text that a user sees or might see on a computer screen. 249 | * 250 | * @see self::renderScreen() 251 | */ 252 | if ($screen = $node->parent('screen')) { 253 | if ($screen->hasRole()) { 254 | return $this->render->codeSnippet($content, $screen->role()); 255 | } 256 | 257 | return $content; 258 | } 259 | 260 | return e($content); 261 | } 262 | 263 | /** 264 | * Render the text node. 265 | */ 266 | protected function renderText(Node $node): Slotable|string 267 | { 268 | /** 269 | * Text that a user sees or might see on a computer screen. 270 | * 271 | * @see self::renderScreen() 272 | */ 273 | if ($screen = $node->ancestor('screen')) { 274 | if ($screen->hasRole()) { 275 | return $this->render->codeSnippet($node->value, $screen->role()); 276 | } 277 | 278 | return $node->value; 279 | } 280 | 281 | return e($node->value); 282 | } 283 | 284 | /** 285 | * An abbreviation, especially one followed by a period. 286 | * 287 | * @see https://tdg.docbook.org/tdg/5.2/abbrev.html 288 | * 289 | * @todo It would be nice if these had a "title" tag for accessiblity. We 290 | * will likely need more indexers for this. 291 | */ 292 | protected function renderAbbrev(Node $node): Slotable|string 293 | { 294 | return $this->render->tag('abbr'); 295 | } 296 | 297 | /** 298 | * A summary. 299 | * 300 | * @see https://tdg.docbook.org/tdg/5.2/abstract.html 301 | */ 302 | protected function renderAbstract(Node $node): Slotable|string 303 | { 304 | return ''; 305 | } 306 | 307 | /** 308 | * An often pronounceable word made from the initial (or selected) letters of a name or phrase. 309 | * 310 | * @see https://tdg.docbook.org/tdg/5.2/acronym.html 311 | * 312 | * @todo It would be nice if these had a "title" tag for accessiblity. We 313 | * will likely need more indexers for this. 314 | */ 315 | protected function renderAcronym(Node $node): Slotable|string 316 | { 317 | return $this->render->tag('abbr'); 318 | } 319 | 320 | /** 321 | * A text-only annotation, often used for accessibility. 322 | * 323 | * @see https://tdg.docbook.org/tdg/5.2/alt.html 324 | * 325 | * @todo improve this 326 | */ 327 | protected function renderAlt(Node $node): Slotable|string 328 | { 329 | return ''; 330 | } 331 | 332 | /** 333 | * The name of an individual author. 334 | * 335 | * This tag only appears at he beginning of the documentation to credit the 336 | * authors, so it does not need to be generic and handle any situation. 337 | * 338 | * @see https://tdg.docbook.org/tdg/5.2/author.html 339 | * @see self::renderAuthorGroup() 340 | */ 341 | protected function renderAuthor(Node $node): Slotable|string 342 | { 343 | $authorgroup = $node->expectParent('authorgroup'); 344 | 345 | if ($authorgroup->id() === 'authors') { 346 | return $this->render->tag('li'); 347 | } 348 | 349 | if ($authorgroup->id() === 'editors') { 350 | return ''; 351 | } 352 | 353 | $this->unhandledNode($node, 'Unknown parent ID for "authorgroup".'); 354 | } 355 | 356 | /** 357 | * A book. 358 | * 359 | * @see https://tdg.docbook.org/tdg/5.2/book.html 360 | */ 361 | protected function renderBook(Node $node): Slotable|string 362 | { 363 | return ''; 364 | } 365 | 366 | /** 367 | * A caption. 368 | * 369 | * @see https://tdg.docbook.org/tdg/5.2/caption.html 370 | */ 371 | protected function renderCaption(Node $node): Slotable|string 372 | { 373 | // TODO this needs to be included in the image indexer and should be rendered 374 | // as a figurecaption. 375 | return ''; 376 | } 377 | 378 | /** 379 | * The name of a class, in the object-oriented programming sense. 380 | * 381 | * @see https://tdg.docbook.org/tdg/5.2/classname.html 382 | */ 383 | protected function renderClassName(Node $node): Slotable|string 384 | { 385 | $link = $this->render->component('link', [ 386 | 'link' => Link::internal("class.{$node->innerContent()}"), 387 | ]); 388 | 389 | return $this->render->component('inline-code')->wrapSlot($link); 390 | } 391 | 392 | /** 393 | * Wrapper for author information when a document has multiple authors or 394 | * collaborators. 395 | * 396 | * This tag only appears at he beginning of the documentation to credit the 397 | * authors, so it does not need to be generic and handle any situation. 398 | * 399 | * @see https://tdg.docbook.org/tdg/5.2/authorgroup.html 400 | */ 401 | protected function renderAuthorGroup(Node $node): Slotable|string 402 | { 403 | if ($node->id() === 'authors') { 404 | return $this->render->component('authors'); 405 | } 406 | 407 | if ($node->id() === 'editors') { 408 | return ''; 409 | } 410 | 411 | $this->unhandledNode($node, 'Unknown ID for "authorgroup".'); 412 | } 413 | 414 | /** 415 | * A note of caution. 416 | * 417 | * @see https://tdg.docbook.org/tdg/5.2/caution.html 418 | */ 419 | protected function renderCaution(Node $node): Slotable|string 420 | { 421 | return $this->render->component('caution'); 422 | } 423 | 424 | /** 425 | * A chapter, as of a book. 426 | * 427 | * @see https://tdg.docbook.org/tdg/5.2/chapter.html 428 | */ 429 | protected function renderChapter(Node $node): Slotable|string 430 | { 431 | return ''; 432 | } 433 | 434 | /** 435 | * An inline code fragment. 436 | * 437 | * @see https://tdg.docbook.org/tdg/5.2/code.html 438 | */ 439 | protected function renderCode(Node $node): Slotable|string 440 | { 441 | return $this->render->component('inline-code'); 442 | } 443 | 444 | /** 445 | * The name of an executable program or other software command. 446 | * 447 | * @see https://tdg.docbook.org/tdg/5.2/command.html 448 | */ 449 | protected function renderCommand(Node $node): Slotable|string 450 | { 451 | return $this->render->component('inline-code'); 452 | } 453 | 454 | /** 455 | * Data, generally text, displayed or presented by a computer. 456 | * 457 | * @see https://tdg.docbook.org/tdg/5.2/computeroutput.html 458 | */ 459 | protected function renderComputerOutput(Node $node): Slotable|string 460 | { 461 | return $this->render->component('inline-code'); 462 | } 463 | 464 | /** 465 | * A programming or system constant. 466 | * 467 | * @see https://tdg.docbook.org/tdg/5.2/constant.html 468 | */ 469 | protected function renderConstant(Node $node): Slotable|string 470 | { 471 | if ($node->hasParent('title')) { 472 | return $this->render->tag( 473 | as: 'var', 474 | class: 'font-mono not-italic', 475 | ); 476 | } 477 | 478 | return $this->render->component('inline-code', [ 479 | 'as' => 'var', 480 | ]); 481 | } 482 | 483 | /** 484 | * Copyright information about a document. 485 | * 486 | * @see https://tdg.docbook.org/tdg/5.2/copyright.html 487 | */ 488 | protected function renderCopyright(Node $node): Slotable|string 489 | { 490 | return ' © '; 491 | } 492 | 493 | /** 494 | * A timestamp processing instruction. 495 | * 496 | * @see http://www.sagehill.net/docbookxsl/Datetime.html 497 | * @see https://www.php.net/manual/en/datetime.format.php 498 | */ 499 | protected function renderDbTimestamp(Node $node): Slotable|string 500 | { 501 | preg_match('/.*?format="(.*?)"/', $node->value, $matches); 502 | 503 | return with(new DateTimeImmutable, fn (DateTimeImmutable $now) => collect(mb_str_split($matches[1])) 504 | ->map(fn (string $component) => match ($component) { 505 | 'a' => $now->format('D'), 506 | 'A' => $now->format('l'), 507 | 'b' => $now->format('M'), 508 | 'c' => $now->format('c'), 509 | 'B' => $now->format('F'), 510 | 'd' => str_contains($node->value, ' padding="0"') 511 | ? ltrim($now->format('d'), '0') 512 | : $now->format('d'), 513 | 'H' => str_contains($node->value, ' padding="0"') 514 | ? ltrim($now->format('H'), '0') 515 | : $now->format('H'), 516 | 'j' => $now->format('z'), 517 | 'm' => str_contains($node->value, ' padding="0"') 518 | ? ltrim($now->format('m'), '0') 519 | : $now->format('m'), 520 | 'M' => str_contains($node->value, ' padding="0"') 521 | ? ltrim($now->format('i'), '0') 522 | : $now->format('i'), 523 | 'S' => str_contains($node->value, ' padding="0"') 524 | ? ltrim($now->format('s'), '0') 525 | : $now->format('s'), 526 | 'U' => $now->format('W'), 527 | 'w' => (string) ($now->format('w') + 1), // spec has Sunday at 1. PHP has Sunday at 0. 528 | 'x' => $now->format('Y-m-dP'), 529 | 'X' => $now->format('H:i:sP'), 530 | 'Y' => $now->format('Y'), 531 | default => $component, 532 | })->pipe(fn (Collection $components) => $this->render->tag( 533 | as: 'time', 534 | before: $components->implode(''), 535 | attributes: [ 536 | 'datetime' => $now->format('c'), 537 | ], 538 | )->toString())); 539 | } 540 | 541 | /** 542 | * The name of the editor of a document. 543 | * 544 | * This tag only appears at he beginning of the documentation to credit the 545 | * authors, so it does not need to be generic and handle any situation. 546 | * 547 | * @see https://tdg.docbook.org/tdg/5.2/editor.html 548 | */ 549 | protected function renderEditor(Node $node): Slotable|string 550 | { 551 | return $this->render->component('editors'); 552 | } 553 | 554 | /** 555 | * Emphasized text. 556 | * 557 | * @see https://tdg.docbook.org/tdg/5.2/emphasis.html 558 | */ 559 | protected function renderEmphasis(Node $node): Slotable|string 560 | { 561 | return $this->render->tag('em'); 562 | } 563 | 564 | /** 565 | * A cell in a table. 566 | * 567 | * @see https://tdg.docbook.org/tdg/5.2/entry.html 568 | */ 569 | protected function renderEntry(Node $node): Slotable|string 570 | { 571 | return $this->render->tag( 572 | as: $node->hasParent('row.thead') ? 'th' : 'td', 573 | class: 'py-2 px-3 text-left first-of-type:pl-6 last:pr-6 tabular-nums', 574 | ); 575 | } 576 | 577 | /** 578 | * A software environment variable. 579 | * 580 | * @see https://tdg.docbook.org/tdg/5.2/envar.html 581 | */ 582 | protected function renderEnVar(Node $node): Slotable|string 583 | { 584 | return $this->render->component('inline-code', [ 585 | 'as' => 'var', 586 | ]); 587 | } 588 | 589 | /** 590 | * A formal example, with a title. 591 | * 592 | * @see https://tdg.docbook.org/tdg/5.2/example.html 593 | * 594 | * @todo I think this needs improving. It might be a figure, but any 595 | * children might need to know about the figure and be figcaption or 596 | * something. 597 | */ 598 | protected function renderExample(Node $node): Slotable|string 599 | { 600 | return $this->render->tag( 601 | as: 'figure', 602 | class: 'my-6', 603 | ); 604 | } 605 | 606 | /** 607 | * The name of a file. 608 | * 609 | * @see https://tdg.docbook.org/tdg/5.2/filename.html 610 | */ 611 | protected function renderFilename(Node $node): Slotable|string 612 | { 613 | if ($node->hasParent('title')) { 614 | return $this->render->tag('code'); 615 | } 616 | 617 | return $this->render->component('emphasised-literal'); 618 | } 619 | 620 | /** 621 | * A given name of a person. 622 | * 623 | * This tag only appears at he beginning of the documentation to credit the 624 | * authors, so it does not need to be generic and handle any situation. 625 | * 626 | * @see https://tdg.docbook.org/tdg/5.2/firstname.html 627 | */ 628 | protected function renderFirstName(Node $node): Slotable|string 629 | { 630 | return $this->render->inlineText(); 631 | } 632 | 633 | /** 634 | * The name of a function or subroutine, as in a programming language. 635 | * 636 | * @see https://tdg.docbook.org/tdg/5.2/function.html 637 | */ 638 | protected function renderFunction(Node $node): Slotable|string 639 | { 640 | $this->tooltipIndex++; 641 | 642 | // TODO handle when a function has multiple prototypes. 643 | $function = $this->functionIndex->all()[$node->innerContent()][0] ?? null; 644 | 645 | // We can't actually do this. Just a step along the way to make it work... 646 | if ($function === null) { 647 | return ''; 648 | } 649 | 650 | return $this->render->wrapper( 651 | slot: $this->render->component('inline-code', [ 652 | 'class' => ['pr-7 relative'], 653 | 'attributes' => [ 654 | 'aria-describedby' => 'tooltip', 655 | ]]) 656 | ->wrapSlot($this->render->wrapper( 657 | slot: $this->render->component('link', [ 658 | 'link' => Link::internal("function.{$function->name}"), 659 | ]), 660 | after: $this->render->tag( 661 | as: 'button', 662 | class: 'flex items-center justify-center ml-2 h-full w-6 border-l border-violet-200 absolute right-0 top-0 rounded-r', 663 | attributes: [ 664 | 'tooltip-target' => "tooltip-{$this->tooltipIndex}", 665 | ], 666 | before: new HtmlString(<<<'HTML' 667 | 668 | 669 | 670 | 671 | 672 | 673 | HTML), 674 | after: $this->render->component('function-popup', [ 675 | 'id' => "tooltip-{$this->tooltipIndex}", 676 | 'method' => $function, 677 | ]), 678 | ), 679 | )), 680 | ); 681 | } 682 | 683 | /** 684 | * Pointer to external image data. 685 | * 686 | * @see https://tdg.docbook.org/tdg/5.2/imagedata.html 687 | */ 688 | protected function renderImageData(Node $node): Slotable|string 689 | { 690 | // TODO: this needs to point to a public image. We need an indexer that 691 | // publishes the files, then we can just `pull` the image when we encounter 692 | // it while generating. Should also include the `alt` text. 693 | return $this->render->tag( 694 | as: 'img', 695 | attributes: [ 696 | 'src' => $node->attribute('fileref'), 697 | ], 698 | ); 699 | } 700 | 701 | /** 702 | * A wrapper for image data and its associated meta-information. 703 | * 704 | * @see https://tdg.docbook.org/tdg/5.2/imageobject.html 705 | */ 706 | protected function renderImageObject(Node $node): Slotable|string 707 | { 708 | return ''; 709 | } 710 | 711 | /** 712 | * The name of the individual or organization that holds a copyright. 713 | * 714 | * This tag is only used on the homepage to highlight the "PHP 715 | * Documentation Group" 716 | * 717 | * @see https://tdg.docbook.org/tdg/5.2/holder.html 718 | * @see self::copyright() 719 | */ 720 | protected function renderHolder(Node $node): Slotable|string 721 | { 722 | return $this->render->inlineText(after: '.'); 723 | } 724 | 725 | /** 726 | * A wrapper for information about a component or other block. 727 | * 728 | * @see https://tdg.docbook.org/tdg/5.2/info.html 729 | */ 730 | protected function renderInfo(Node $node): Slotable|string 731 | { 732 | return ''; 733 | } 734 | 735 | /** 736 | * A displayed example without a title. 737 | * 738 | * @todo I think this needs improving. It might be a figure, but any 739 | * children might need to know about the figure and be figcaption or 740 | * something. 741 | * 742 | * @see https://tdg.docbook.org/tdg/5.2/informalexample.html 743 | */ 744 | protected function renderInformalExample(Node $node): Slotable|string 745 | { 746 | return $this->render->tag( 747 | as: 'figure', 748 | class: 'my-6', 749 | ); 750 | } 751 | 752 | /** 753 | * An HTML table without a title. 754 | * 755 | * @see https://tdg.docbook.org/tdg/5.2/html.informaltable.html 756 | */ 757 | protected function renderInformalTable(Node $node): Slotable|string 758 | { 759 | return $this->renderTable($node); 760 | } 761 | 762 | /** 763 | * The name of an interface. 764 | * 765 | * @see https://tdg.docbook.org/tdg/5.2/interfacename.html 766 | */ 767 | protected function renderInterfaceName(Node $node): Slotable|string 768 | { 769 | $link = $this->render->component('link', [ 770 | 'link' => Link::internal("class.{$node->innerContent()}"), 771 | ]); 772 | 773 | return $this->render->component('inline-code')->wrapSlot($link); 774 | } 775 | 776 | /** 777 | * A list in which each entry is marked with a bullet or other dingbat. 778 | * 779 | * @see https://tdg.docbook.org/tdg/5.2/itemizedlist.html 780 | */ 781 | protected function renderItemizedList(Node $node): Slotable|string 782 | { 783 | return $this->render->component('unordered-list'); 784 | } 785 | 786 | /** 787 | * A statement of legal obligations or requirements. 788 | * 789 | * @see https://tdg.docbook.org/tdg/5.2/legalnotice.html 790 | */ 791 | protected function renderLegalNotice(Node $node): Slotable|string 792 | { 793 | return ''; 794 | } 795 | 796 | /** 797 | * A hypertext link. 798 | * 799 | * @see https://tdg.docbook.org/tdg/5.2/link.html 800 | */ 801 | protected function renderLink(Node $node): Slotable|string 802 | { 803 | // All titles contain "hash" links which I feel this is more valuable 804 | // than providing in-title links. We don't wanna be wrapping links 805 | // within links. That is a bad time for everyone. 806 | if ($node->hasAncestor('title')) { 807 | return ''; 808 | } 809 | 810 | return $this->render->component('link', [ 811 | 'link' => $node->link(), 812 | ]); 813 | } 814 | 815 | /** 816 | * A wrapper for the elements of a list item. 817 | * 818 | * @see https://tdg.docbook.org/tdg/5.2/listitem.html 819 | */ 820 | protected function renderListItem(Node $node): Slotable|string 821 | { 822 | if ($node->hasParent('varlistentry')) { 823 | return $this->render->tag( 824 | as: 'dd', 825 | class: 'mt-2', 826 | ); 827 | } 828 | 829 | return $this->render->tag('li'); 830 | } 831 | 832 | /** 833 | * A displayed media object (video, audio, image, etc.). 834 | * 835 | * @see https://tdg.docbook.org/tdg/5.2/mediaobject.html 836 | */ 837 | protected function renderMediaObject(Node $node): Slotable|string 838 | { 839 | return $this->lazy = new class($this->render) implements Wrapper { 840 | public string $alt; 841 | public string $src; 842 | public string $caption; 843 | 844 | public function __construct( 845 | private Factory $render, 846 | ) { 847 | // 848 | } 849 | 850 | public function before(): string 851 | { 852 | return ''; 853 | } 854 | 855 | public function after(): string 856 | { 857 | return << 859 | {$this->alt} 860 |
{$this->caption}
861 | 862 | HTML; 863 | } 864 | }; 865 | } 866 | 867 | /** 868 | * An element of a simple list. 869 | * 870 | * @see https://tdg.docbook.org/tdg/5.2/member.html 871 | */ 872 | protected function renderMember(Node $node): Slotable|string 873 | { 874 | return $this->render->tag('li'); 875 | } 876 | 877 | /** 878 | * Inline text that is some literal value. 879 | * 880 | * @see https://tdg.docbook.org/tdg/5.2/literal.html 881 | */ 882 | protected function renderLiteral(Node $node): Slotable|string 883 | { 884 | return $this->render->component('inline-code'); 885 | } 886 | 887 | /** 888 | * A block of text in which line breaks and white space are to be reproduced faithfully. 889 | * 890 | * @see https://tdg.docbook.org/tdg/5.2/literallayout.html 891 | */ 892 | protected function renderLiteralLayout(Node $node): Slotable|string 893 | { 894 | return $this->render->tag('pre'); 895 | } 896 | 897 | /** 898 | * A message set off from the text. 899 | * 900 | * @see https://tdg.docbook.org/tdg/5.2/note.html 901 | */ 902 | protected function renderNote(Node $node): Slotable|string 903 | { 904 | return $this->render->component('note'); 905 | } 906 | 907 | /** 908 | * An option for a software command. 909 | * 910 | * @see https://tdg.docbook.org/tdg/5.2/option.html 911 | */ 912 | protected function renderOption(Node $node): Slotable|string 913 | { 914 | return $this->render->component('emphasised-literal'); 915 | } 916 | 917 | /** 918 | * Optional information. 919 | * 920 | * @see https://tdg.docbook.org/tdg/5.2/optional.html 921 | */ 922 | protected function renderOptional(Node $node): Slotable|string 923 | { 924 | return $this->render->wrapper( 925 | before: '[', 926 | after: ']', 927 | ); 928 | } 929 | 930 | /** 931 | * A list in which each entry is marked with a sequentially incremented label. 932 | * 933 | * @see https://tdg.docbook.org/tdg/5.2/orderedlist.html 934 | */ 935 | protected function renderOrderedList(Node $node): Slotable|string 936 | { 937 | return $this->render->component('ordered-list', [ 938 | 'type' => $node->numeration(), 939 | ]); 940 | } 941 | 942 | /** 943 | * A person or entity, other than an author or editor, credited in a document. 944 | * 945 | * This tag only appears at he beginning of the documentation to credit the 946 | * authors, so it does not need to be generic and handle any situation. 947 | * 948 | * @see https://tdg.docbook.org/tdg/5.2/othercredit.html 949 | */ 950 | protected function renderOtherCredit(Node $node): Slotable|string 951 | { 952 | if ($authorGroup->id() === 'authors') { 953 | return $this->render->tag('li'); 954 | } 955 | 956 | $this->unhandledNode($node, 'Unknown parent ID for othercredit.'); 957 | } 958 | 959 | /** 960 | * A component of a person's name that is not a first name, surname, or lineage. 961 | * 962 | * @see https://tdg.docbook.org/tdg/5.2/othername.html 963 | */ 964 | protected function renderOtherName(Node $node): Slotable|string 965 | { 966 | return ''; 967 | } 968 | 969 | /** 970 | * A paragraph. 971 | * 972 | * @see https://tdg.docbook.org/tdg/5.2/para.html 973 | */ 974 | protected function renderPara(Node $node): Slotable|string 975 | { 976 | // Putting paragraph tags in a `
  • ` breaks the HTML flow. We don't 977 | // need it here...as far as I can tell. 978 | if ($node->hasParent('listitem')) { 979 | return ''; 980 | } 981 | 982 | return $this->render->component('paragraph'); 983 | } 984 | 985 | /** 986 | * A value or a symbolic reference to a value. 987 | * 988 | * @see https://tdg.docbook.org/tdg/5.2/parameter.html 989 | */ 990 | protected function renderParameter(Node $node): Slotable|string 991 | { 992 | return $this->render->component('emphasised-literal'); 993 | } 994 | 995 | /** 996 | * The personal name of an individual. 997 | * 998 | * @see https://tdg.docbook.org/tdg/5.2/personname.html 999 | */ 1000 | protected function renderPersonName(Node $node): Slotable|string 1001 | { 1002 | return ''; 1003 | } 1004 | 1005 | /** 1006 | * Unused tag. 1007 | */ 1008 | protected function renderPhpDoc(Node $node): Slotable|string 1009 | { 1010 | return ''; 1011 | } 1012 | 1013 | /** 1014 | * Introductory matter preceding the first chapter of a book. 1015 | * 1016 | * @see https://tdg.docbook.org/tdg/5.2/preface.html 1017 | */ 1018 | protected function renderPreface(Node $node): Slotable|string 1019 | { 1020 | return ''; 1021 | } 1022 | 1023 | /** 1024 | * A list of operations to be performed in a well-defined sequence. 1025 | * 1026 | * @see https://tdg.docbook.org/tdg/5.2/procedure.html 1027 | */ 1028 | protected function renderProcedure(Node $node): Slotable|string 1029 | { 1030 | return $this->render->component('ordered-list'); 1031 | } 1032 | 1033 | /** 1034 | * The name of a method. 1035 | * 1036 | * @see https://tdg.docbook.org/tdg/5.2/methodname.html 1037 | */ 1038 | protected function renderMethodName(Node $node): Slotable|string 1039 | { 1040 | // TODO this should show function API on hover. 1041 | // TODO this should link to the method 1042 | return $this->render->component('inline-code'); 1043 | } 1044 | 1045 | /** 1046 | * Modifiers in a synopsis. 1047 | * 1048 | * @see https://tdg.docbook.org/tdg/5.2/modifier.html 1049 | */ 1050 | protected function renderModifier(Node $node): Slotable|string 1051 | { 1052 | // TODO 1053 | return $this->render->component('inline-code'); 1054 | } 1055 | 1056 | /** 1057 | * The formal name of a product. 1058 | * 1059 | * @see https://tdg.docbook.org/tdg/5.2/productname.html 1060 | */ 1061 | protected function renderProductName(Node $node): Slotable|string 1062 | { 1063 | return ''; 1064 | } 1065 | 1066 | /** 1067 | * A literal listing of all or part of a program. 1068 | * 1069 | * @see https://tdg.docbook.org/tdg/5.2/programlisting.html 1070 | */ 1071 | protected function renderProgramListing(Node $node): Slotable|string 1072 | { 1073 | return $this->render->component('program-listing'); 1074 | } 1075 | 1076 | /** 1077 | * The date of publication of a document. 1078 | * 1079 | * @see https://tdg.docbook.org/tdg/5.2/pubdate.html 1080 | */ 1081 | protected function renderPubDate(Node $node): Slotable|string 1082 | { 1083 | if ($node->parent('info.set')?->hasNoParent()) { 1084 | return $this->render->inlineText( 1085 | before: 'Published ', 1086 | after: '.', 1087 | ); 1088 | } 1089 | 1090 | $this->unhandledNode($node, 'Generic pubdate component not implemented.'); 1091 | } 1092 | 1093 | /** 1094 | * Content that may or must be replaced by the user. 1095 | * 1096 | * @see https://tdg.docbook.org/tdg/5.2/replaceable.html 1097 | */ 1098 | protected function renderReplaceable(Node $node): Slotable|string 1099 | { 1100 | return $this->render->wrapper( 1101 | before: '{', 1102 | after: '}', 1103 | ); 1104 | } 1105 | 1106 | /** 1107 | * A row in a table. 1108 | * 1109 | * @see https://tdg.docbook.org/tdg/5.2/row.html 1110 | */ 1111 | protected function renderRow(Node $node): Slotable|string 1112 | { 1113 | return $this->render->tag( 1114 | as: 'tr', 1115 | class: 'border-b border-violet-50 even:bg-violet-25', 1116 | ); 1117 | } 1118 | 1119 | /** 1120 | * Text that a user sees or might see on a computer screen. 1121 | * 1122 | * @see https://tdg.docbook.org/tdg/5.2/screen.html 1123 | */ 1124 | protected function renderScreen(Node $node): Slotable|string 1125 | { 1126 | return $this->render->component('screen'); 1127 | } 1128 | 1129 | /** 1130 | * A top-level section of document. 1131 | * 1132 | * @see https://tdg.docbook.org/tdg/5.2/sect1.html 1133 | */ 1134 | protected function renderSect1(Node $node): Slotable|string 1135 | { 1136 | return ''; 1137 | } 1138 | 1139 | /** 1140 | * A subsection within a sect1. 1141 | * 1142 | * @see https://tdg.docbook.org/tdg/5.2/sect2.html 1143 | */ 1144 | protected function renderSect2(Node $node): Slotable|string 1145 | { 1146 | return ''; 1147 | } 1148 | 1149 | /** 1150 | * A subsection within a sect2. 1151 | * 1152 | * @see https://tdg.docbook.org/tdg/5.2/sect3.html 1153 | */ 1154 | protected function renderSect3(Node $node): Slotable|string 1155 | { 1156 | return ''; 1157 | } 1158 | 1159 | /** 1160 | * A subsection within a sect3. 1161 | * 1162 | * @see https://tdg.docbook.org/tdg/5.2/sect4.html 1163 | */ 1164 | protected function renderSect4(Node $node): Slotable|string 1165 | { 1166 | return ''; 1167 | } 1168 | 1169 | /** 1170 | * A recursive section. 1171 | * 1172 | * @see https://tdg.docbook.org/tdg/5.2/section.html 1173 | */ 1174 | protected function renderSection(Node $node): Slotable|string 1175 | { 1176 | return ''; 1177 | } 1178 | 1179 | /** 1180 | * A collection of books. 1181 | * 1182 | * @see https://tdg.docbook.org/tdg/5.2/set.html 1183 | */ 1184 | protected function renderSet(Node $node): Slotable|string 1185 | { 1186 | return ''; 1187 | } 1188 | 1189 | /** 1190 | * A paragraph that contains only text and inline markup, no block elements. 1191 | * 1192 | * @see https://tdg.docbook.org/tdg/5.2/simpara.html 1193 | */ 1194 | protected function renderSimPara(Node $node): Slotable|string 1195 | { 1196 | return $this->renderPara($node); 1197 | } 1198 | 1199 | /** 1200 | * An undecorated list of single words or short phrases. 1201 | * 1202 | * @see https://tdg.docbook.org/tdg/5.2/simplelist.html 1203 | */ 1204 | protected function renderSimpleList(Node $node): Slotable|string 1205 | { 1206 | return $this->render->component('unordered-list'); 1207 | } 1208 | 1209 | /** 1210 | * A unit of action in a procedure. 1211 | * 1212 | * @see https://tdg.docbook.org/tdg/5.2/step.html 1213 | * @see self::renderProcedure() 1214 | */ 1215 | protected function renderStep(Node $node): Slotable|string 1216 | { 1217 | return $this->render->tag('li'); 1218 | } 1219 | 1220 | /** 1221 | * An inherited or family name; in western cultures the last name. 1222 | * 1223 | * This tag only appears at he beginning of the documentation to credit the 1224 | * authors, so it does not need to be generic and handle any situation. 1225 | * 1226 | * @see https://tdg.docbook.org/tdg/5.2/surname.html 1227 | */ 1228 | protected function renderSurname(Node $node): Slotable|string 1229 | { 1230 | return $this->render->inlineText(); 1231 | } 1232 | 1233 | /** 1234 | * A general-purpose element for representing the syntax of commands or functions. 1235 | * 1236 | * @see https://tdg.docbook.org/tdg/5.2/synopsis.html 1237 | */ 1238 | protected function renderSynopsis(Node $node): Slotable|string 1239 | { 1240 | // Maybe not this? 1241 | return $this->render->component('program-listing'); 1242 | } 1243 | 1244 | /** 1245 | * A system-related item or term. 1246 | * 1247 | * @see https://tdg.docbook.org/tdg/5.2/systemitem.html 1248 | */ 1249 | protected function renderSystemItem(Node $node): Slotable|string 1250 | { 1251 | return $this->render->component('emphasised-literal'); 1252 | } 1253 | 1254 | /** 1255 | * A formal (captioned) HTML table in a document. 1256 | * 1257 | * @see https://tdg.docbook.org/tdg/5.2/html.table.html 1258 | */ 1259 | protected function renderTable(Node $node): Slotable|string 1260 | { 1261 | return $this->render->tag( 1262 | as: 'table', 1263 | class: 'my-6 w-full border-t border-l border-r border-violet-50', 1264 | ); 1265 | } 1266 | 1267 | /** 1268 | * A wrapper for the rows of an HTML table or informal HTML table. 1269 | * 1270 | * @see https://tdg.docbook.org/tdg/5.2/html.tbody.html 1271 | */ 1272 | protected function renderTBody(Node $node): Slotable|string 1273 | { 1274 | return $this->render->tag('tbody'); 1275 | } 1276 | 1277 | /** 1278 | * The word or phrase being defined or described in a variable list. 1279 | * 1280 | * @see https://tdg.docbook.org/tdg/5.2/term.html 1281 | */ 1282 | protected function renderTerm(Node $node): Slotable|string 1283 | { 1284 | $parent = $node->expectParent('varlistentry'); 1285 | 1286 | return $this->render->tag( 1287 | as: 'dt', 1288 | class: 'space-x-2', 1289 | attributes: [ 1290 | 'id' => $parent->hasId() ? $parent->id() : false, 1291 | ], 1292 | ); 1293 | } 1294 | 1295 | /** 1296 | * A wrapper for the main content of a table, or part of a table. 1297 | * 1298 | * @see https://tdg.docbook.org/tdg/5.2/tgroup.html 1299 | */ 1300 | protected function renderTGroup(Node $node): Slotable|string 1301 | { 1302 | return ''; 1303 | } 1304 | 1305 | /** 1306 | * A table header consisting of one or more rows in an HTML table. 1307 | * 1308 | * @see https://tdg.docbook.org/tdg/5.2/html.thead.html 1309 | */ 1310 | protected function renderTHead(Node $node): Slotable|string 1311 | { 1312 | return $this->render->tag( 1313 | as: 'thead', 1314 | class: 'bg-violet-50 text-violet-950 font-bold border-b border-violet-100', 1315 | ); 1316 | } 1317 | 1318 | /** 1319 | * A suggestion to the user, set off from the text. 1320 | * 1321 | * @see https://tdg.docbook.org/tdg/5.2/tip.html 1322 | */ 1323 | protected function renderTip(Node $node): Slotable|string 1324 | { 1325 | return $this->render->component('tip'); 1326 | } 1327 | 1328 | /** 1329 | * The text of the title of a section of a document or of a formal block-level element. 1330 | * 1331 | * @see https://tdg.docbook.org/tdg/5.2/title.html 1332 | */ 1333 | protected function renderTitle(Node $node): Slotable|string 1334 | { 1335 | // The headings in the preface are a special case. We will just force 1336 | // them to be level two without trying to do anything special. 1337 | if ($node->ancestor('section')?->hasAncestor('preface')) { 1338 | return $this->render->component('title', [ 1339 | 'level' => 2, 1340 | 'link' => Link::fragment($node->hasId() ? $node->id() : $node->innerContent()), 1341 | ]); 1342 | } 1343 | 1344 | // Currently we hard code all note titles `h3`. I would love this to be 1345 | // improved. 1346 | if ($node->hasAncestor('note')) { 1347 | return $this->render->component('note.title', [ 1348 | 'link' => Link::fragment($node->innerContent()), 1349 | ]); 1350 | } 1351 | 1352 | if ($node->hasAncestor('tip')) { 1353 | return $this->render->component('tip.title', [ 1354 | 'link' => Link::fragment($node->innerContent()), 1355 | ]); 1356 | } 1357 | 1358 | // Currently we hard code all example titles `h2`. I would love this to 1359 | // be improved. 1360 | if ($node->hasParent('info.example')) { 1361 | return $this->render->component('title', [ 1362 | 'level' => 2, 1363 | 'link' => Link::fragment($node->innerContent()), 1364 | ]); 1365 | } 1366 | 1367 | /* 1368 | * Now we will check if the node is contained within the title index. 1369 | * If it is, we assume it is a page title, as the index only contains 1370 | * the main page chunked titles. 1371 | */ 1372 | 1373 | [$section] = $this->titleIndex->info($node); 1374 | 1375 | if ($section !== null) { 1376 | return $this->render->component('title', [ 1377 | 'level' => 1, 1378 | 'link' => Link::fragment($section->id()), 1379 | ]); 1380 | } 1381 | 1382 | /** 1383 | * @todo this needs to caclculate the title depth to better set the "level" 1384 | */ 1385 | 1386 | return $this->render->component('title', [ 1387 | 'level' => 2, 1388 | 'link' => Link::fragment($node->innerContent()), 1389 | ]); 1390 | } 1391 | 1392 | /** 1393 | * The abbreviation of a title. 1394 | * 1395 | * @see https://tdg.docbook.org/tdg/5.2/titleabbrev.html 1396 | */ 1397 | protected function renderTitleAbbrev(Node $node): Slotable|string 1398 | { 1399 | // TODO: would be cool if we could return a "skip" instruction to just skip over the entire node and its inner content. 1400 | // This is only used for the menu. 1401 | return ''; 1402 | } 1403 | 1404 | /** 1405 | * Data entered by the user. 1406 | * 1407 | * @see https://tdg.docbook.org/tdg/5.2/userinput.html 1408 | */ 1409 | protected function renderUserInput(Node $node): Slotable|string 1410 | { 1411 | return $this->render->component('inline-code'); 1412 | } 1413 | 1414 | /** 1415 | * The classification of a value. 1416 | * 1417 | * @see https://tdg.docbook.org/tdg/5.2/type.html 1418 | */ 1419 | protected function renderType(Node $node): Slotable|string 1420 | { 1421 | $link = $this->render->component('link', [ 1422 | 'link' => Link::internal(match (strtolower($node->innerContent())) { 1423 | 'enum' => 'language.types.enumerations', 1424 | 'int' => 'language.types.integer', 1425 | 'bool' => 'language.types.boolean', 1426 | 'string' => 'language.types.string', 1427 | 'mixed' => 'language.types.mixed', 1428 | 'null' => 'language.types.null', 1429 | 'float' => 'language.types.float', 1430 | 'array' => 'language.types.array', 1431 | 'object' => 'language.types.object', 1432 | 'callable' => 'language.types.callable', 1433 | 'resource' => 'language.types.resource', 1434 | 'never' => 'language.types.never', 1435 | 'void' => 'language.types.void', 1436 | 'self', 'parent', 'static' => 'language.types.relative-class-types', 1437 | 'false' => 'reserved.constants#constant.false', 1438 | 'true' => 'reserved.constants#constant.true', 1439 | 'iterable' => 'language.types.iterable', 1440 | default => throw new RuntimeException('Unknown type encountered'), 1441 | }), 1442 | ]); 1443 | 1444 | return $this->render->component('inline-code')->wrapSlot($link); 1445 | } 1446 | 1447 | /** 1448 | * A list in which each entry is composed of a set of one or more terms and an associated description. 1449 | * 1450 | * @see https://tdg.docbook.org/tdg/5.2/variablelist.html 1451 | */ 1452 | protected function renderVariableList(Node $node): Slotable|string 1453 | { 1454 | return $this->render->tag( 1455 | as: 'dl', 1456 | class: 'space-y-6', 1457 | ); 1458 | } 1459 | 1460 | /** 1461 | * A wrapper for a set of terms and the associated description in a variable list. 1462 | * 1463 | * @see https://tdg.docbook.org/tdg/5.2/varlistentry.html 1464 | */ 1465 | protected function renderVarListEntry(Node $node): Slotable|string 1466 | { 1467 | return $this->render->tag('div'); 1468 | } 1469 | 1470 | /** 1471 | * The name of a variable. 1472 | * 1473 | * @see https://tdg.docbook.org/tdg/5.2/varname.html 1474 | * 1475 | * @todo link to the variable within the documentation. This will likely 1476 | * require further indexing. We can use `wrapSlot` to wrap the slot 1477 | * in a link tag. 1478 | */ 1479 | protected function renderVarName(Node $node): Slotable|string 1480 | { 1481 | return $this->render->component('inline-code', [ 1482 | 'as' => 'var', 1483 | ]); 1484 | // ->wrapSlot($this->render->component('link', [ 1485 | // 'link' => Link::internal('#todo'), 1486 | // ])); 1487 | } 1488 | 1489 | /** 1490 | * An admonition set off from the text. 1491 | * 1492 | * @see https://tdg.docbook.org/tdg/5.2/warning.html 1493 | */ 1494 | protected function renderWarning(Node $node): Slotable|string 1495 | { 1496 | return $this->render->component('warning'); 1497 | } 1498 | 1499 | /** 1500 | * A cross reference to another part of the document. 1501 | * 1502 | * @see https://tdg.docbook.org/tdg/5.2/xref.html 1503 | */ 1504 | protected function renderXref(Node $node): Slotable|string 1505 | { 1506 | return $this->render->component('link', [ 1507 | 'link' => $node->link(), 1508 | 'text' => $node->link()->destination, 1509 | ]); 1510 | } 1511 | 1512 | /** 1513 | * The year of publication of a document. 1514 | * 1515 | * @see https://tdg.docbook.org/tdg/5.2/year.html 1516 | * @see self::copyright() 1517 | */ 1518 | protected function renderYear(Node $node): Slotable|string 1519 | { 1520 | return ''; 1521 | } 1522 | 1523 | /** 1524 | * Bail on unhandled node. 1525 | */ 1526 | protected function unhandledNode(Node $node, string $reason): never 1527 | { 1528 | throw new RuntimeException("Unhandled node of tag [{$node->name}]. Reason: {$reason}."); 1529 | } 1530 | 1531 | /** 1532 | * Add debugging information to the node. 1533 | */ 1534 | protected function withDebuggingInfo(Node $node, string|Slotable $content): string|Slotable 1535 | { 1536 | if (in_array($node->name, ['#text', '#cdata-section'], true)) { 1537 | return $content; 1538 | } 1539 | 1540 | [$name, $id, $role] = [ 1541 | $node->name, 1542 | $node->hasId() ? $node->id() : '', 1543 | $node->hasRole() ? $node->role() : '', 1544 | ]; 1545 | 1546 | if (is_string($content)) { 1547 | return << 1549 | HTML.$content; 1550 | } 1551 | 1552 | return $this->render->wrapper( 1553 | before: << 1555 | HTML.$content->before(), 1556 | after: $content->after(), 1557 | ); 1558 | } 1559 | } 1560 | -------------------------------------------------------------------------------- /src/Website/Image.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | protected ?Collection $allCache; 37 | 38 | /** 39 | * Create a new instance. 40 | */ 41 | public function __construct( 42 | protected FileStreamFactory $streamFactory, 43 | protected Factory $render, 44 | protected Configuration $config, 45 | ) { 46 | $this->stream = $this->streamFactory->make( 47 | "{$this->config->get('index_directory')}/website/{$this->config->get('language')}/images.php", 48 | 1000, 49 | ); 50 | } 51 | 52 | /** 53 | * Set up. 54 | */ 55 | public function setUp(): void 56 | { 57 | $this->stream->write(<<<'PHP' 58 | stream; 76 | } 77 | 78 | /** 79 | * Determine if the generator should chunk. 80 | */ 81 | public function shouldChunk(Node $node): bool 82 | { 83 | return false; 84 | } 85 | 86 | /** 87 | * Render the given node. 88 | */ 89 | public function render(Node $node): string|Slotable 90 | { 91 | $mediaObject = $node->name === 'mediaobject' 92 | ? $node 93 | : $node->ancestor('mediaobject'); 94 | 95 | if ($mediaObject === null) { 96 | return ''; 97 | } 98 | 99 | return match ($node->name) { 100 | 'mediaobject' => $this->renderImageObject($node), 101 | 'imagedata' => $this->renderImageData($node), 102 | 'caption' => $this->renderCaption($node), 103 | 'alt' => $this->renderAlt($node), 104 | 'link' => $this->renderLink($node), 105 | '#text' => $this->renderText($node), 106 | 'simpara', 107 | 'imageobject' => '', 108 | default => throw new RuntimeException(<<name}] tag found in imageobject. 110 | Update the ImageIndex::render method. 111 | 112 | Content: 113 | {$node->innerContent()} 114 | ERROR), 115 | }; 116 | } 117 | 118 | /** 119 | * Tear down. 120 | */ 121 | public function tearDown(): void 122 | { 123 | $this->stream->write('];'); 124 | } 125 | 126 | /** 127 | * Render an imageobject node. 128 | */ 129 | protected function renderImageObject(Node $node): Slotable 130 | { 131 | $this->fileNumber = 1; 132 | 133 | return $this->render->wrapper( 134 | before: <<<'PHP' 135 | new Image( 136 | PHP, 137 | after: <<<'PHP' 138 | 139 | ), 140 | 141 | 142 | PHP, 143 | ); 144 | } 145 | 146 | /** 147 | * Render an caption node. 148 | */ 149 | protected function renderCaption(Node $node): Slotable 150 | { 151 | return $this->render->wrapper( 152 | before: "\n caption: new HtmlString(<<<'HTML'\n", 153 | after: "\nHTML),", 154 | ); 155 | } 156 | 157 | /** 158 | * Render an imagedata node. 159 | */ 160 | protected function renderImageData(Node $node): string 161 | { 162 | return "\n file".($this->fileNumber++).": {$this->render->export($node->attribute('fileref'))},"; 163 | } 164 | 165 | /** 166 | * Render an alt node. 167 | */ 168 | protected function renderLink(Node $node): Slotable 169 | { 170 | return $this->render->wrapper( 171 | before: << 173 | HTML, 174 | after: <<<'HTML' 175 | 176 | HTML, 177 | ); 178 | } 179 | 180 | /** 181 | * Render an alt node. 182 | */ 183 | protected function renderAlt(Node $node): Slotable 184 | { 185 | return $this->render->wrapper( 186 | before: "\n alt: new HtmlString('", 187 | after: "'),", 188 | ); 189 | } 190 | 191 | /** 192 | * Render a text node. 193 | */ 194 | protected function renderText(Node $node): string 195 | { 196 | return e(Str::squish($node->value)); 197 | } 198 | 199 | /** 200 | * Retrieve all titles from the index. 201 | * 202 | * @return Collection 203 | */ 204 | public function all(): Collection 205 | { 206 | return $this->allCache ??= collect(require $this->stream->path); // @phpstan-ignore argument.templateType, argument.templateType 207 | } 208 | 209 | /** 210 | * Find the title based on it's ID. 211 | */ 212 | public function find(string $id): Title 213 | { 214 | $title = $this->findMany(collect([$id]))->first(); 215 | 216 | if ($title === null) { 217 | throw new RuntimeException("Could not find title with id [{$id}]."); 218 | } 219 | 220 | return $title; 221 | } 222 | 223 | /** 224 | * Find many titles based on their ID. 225 | * 226 | * @param Collection $ids 227 | * @return Collection 228 | */ 229 | public function findMany(Collection $ids): Collection 230 | { 231 | return $this->all()->only($ids); 232 | } 233 | 234 | /** 235 | * Retrieve the title heirachy. 236 | * 237 | * @return Collection 238 | */ 239 | public function heirachy(): Collection 240 | { 241 | return $this->all() 242 | ->reduce(function (Collection $result, Title $title) { 243 | $level = 1; 244 | $children = $result; 245 | 246 | while ($level < $title->level) { 247 | $children = $children->reverse()->first()->children; 248 | 249 | $level++; 250 | } 251 | 252 | $children->push(new Title( 253 | id: $title->id, 254 | lineage: $title->lineage, 255 | level: $level, 256 | html: $result->isEmpty() ? new HtmlString('Home') : $title->html, 257 | )); 258 | 259 | return $result; 260 | }, collect([])); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /src/Website/Method.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public Collection $parameters; 17 | 18 | /** 19 | * The method return types. 20 | * 21 | * @var Collection 22 | */ 23 | public Collection $returnTypes; 24 | 25 | /** 26 | * The method description. 27 | */ 28 | public string $description; 29 | 30 | /** 31 | * Create a new instance. 32 | * 33 | * @param list $returnTypes 34 | */ 35 | public function __construct( 36 | ?string $description, 37 | public string $name, 38 | array $returnTypes = [], 39 | ?Parameter $p1 = null, 40 | ?Parameter $p2 = null, 41 | ?Parameter $p3 = null, 42 | ?Parameter $p4 = null, 43 | ?Parameter $p5 = null, 44 | ?Parameter $p6 = null, 45 | ?Parameter $p7 = null, 46 | ?Parameter $p8 = null, 47 | ?Parameter $p9 = null, 48 | ?Parameter $p10 = null, 49 | ?Parameter $p11 = null, 50 | ?Parameter $p12 = null, 51 | ?Parameter $p13 = null, 52 | ?Parameter $p14 = null, 53 | ?Parameter $p15 = null, 54 | ?Parameter $p16 = null, 55 | ?Parameter $p17 = null, 56 | ?Parameter $p18 = null, 57 | ?Parameter $p19 = null, 58 | ?Parameter $p20 = null, 59 | ) { 60 | $this->description = $description ?? ''; 61 | 62 | $this->returnTypes = collect($returnTypes); 63 | 64 | $this->parameters ??= collect([ // @phpstan-ignore assign.propertyType 65 | $p1, $p2, $p3, $p4, $p5, 66 | $p6, $p7, $p8, $p9, $p10, 67 | $p11, $p12, $p13, $p14, $p15, 68 | $p16, $p17, $p18, $p19, $p20, 69 | ])->filter()->values(); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Website/Parameter.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | public Collection $types; 17 | 18 | /** 19 | * Create a new instance. 20 | * 21 | * @param list $types 22 | */ 23 | public function __construct( 24 | array $types, 25 | public string $name, 26 | ) { 27 | $this->types = collect($types); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Website/Title.php: -------------------------------------------------------------------------------- 1 | $children 16 | */ 17 | public function __construct( 18 | public string $id, 19 | public string $lineage, 20 | public int $level, 21 | public HtmlString $html, 22 | public Collection $children = new Collection, 23 | ) { 24 | // 25 | } 26 | 27 | /** 28 | * Determine if the title is the same or has the given title as a child. 29 | */ 30 | public function isOrHasChild(Title $title): bool 31 | { 32 | return $this->is($title) || $this->hasChild($title); 33 | } 34 | 35 | /** 36 | * Determine if the title is the same as the given title. 37 | */ 38 | public function is(Title $title): bool 39 | { 40 | return $this->id === $title->id; 41 | } 42 | 43 | /** 44 | * Determine if the title has the given title as a child. 45 | */ 46 | public function hasChild(Title $title): bool 47 | { 48 | return $this->children->contains( 49 | fn (Title $child) => $child->is($title) || $child->hasChild($title), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Website/TitleIndex.php: -------------------------------------------------------------------------------- 1 | 35 | */ 36 | protected ?Collection $allCache; 37 | 38 | /** 39 | * Create a new instance. 40 | */ 41 | public function __construct( 42 | protected FileStreamFactory $streamFactory, 43 | protected Factory $render, 44 | protected Configuration $config, 45 | ) { 46 | $this->stream = $this->streamFactory->make( 47 | "{$this->config->get('index_directory')}/website/{$this->config->get('language')}/titles.php", 48 | 1000, 49 | ); 50 | } 51 | 52 | /** 53 | * Set up. 54 | */ 55 | public function setUp(): void 56 | { 57 | $this->stream->write(<<<'PHP' 58 | stream; 76 | } 77 | 78 | /** 79 | * Determine if the generator should chunk. 80 | */ 81 | public function shouldChunk(Node $node): bool 82 | { 83 | return false; 84 | } 85 | 86 | /** 87 | * Render the given node. 88 | */ 89 | public function render(Node $node): string|Slotable 90 | { 91 | // We only care about nodes that are title tags or that are the 92 | // children of title tags. 93 | $title = $node->name === 'title' 94 | ? $node 95 | : $node->ancestor('title'); 96 | 97 | if ($title === null) { 98 | return ''; 99 | } 100 | 101 | // We do not want to capture the stray "PHP Manual" title. 102 | // if ($title->parent('book')?->hasId('manual')) { 103 | // return ''; 104 | // } 105 | 106 | [$section, $level] = $this->info($title); 107 | 108 | if ($section === null) { 109 | return ''; 110 | } 111 | 112 | return match ($node->name) { 113 | 'title' => $this->renderTitle($node, $section, $level), 114 | '#text' => $this->renderText($node), 115 | 'productname' => '', 116 | 'literal', 'command', 'function' => $this->render->tag('code'), 117 | 'classname' => $this->render->tag( 118 | as: 'var', 119 | class: 'not-italic', 120 | ), 121 | default => throw new RuntimeException(<<name}] tag found in title. 123 | Update the TitleIndex::render method. 124 | 125 | Content: 126 | {$node->innerContent()} 127 | ERROR), 128 | }; 129 | } 130 | 131 | /** 132 | * Tear down. 133 | */ 134 | public function tearDown(): void 135 | { 136 | $this->stream->write('];'); 137 | } 138 | 139 | /** 140 | * Render a title node. 141 | */ 142 | protected function renderTitle(Node $title, Node $section, int $level): Slotable 143 | { 144 | return $this->render->wrapper( 145 | before: <<render->export($section->id())} => new Title( 147 | id: {$this->render->export($section->id())}, 148 | lineage: '{$title->lineage()}', 149 | level: {$level}, 150 | html: new HtmlString(<<<'HTML' 151 | 152 | PHP, 153 | after: <<<'PHP' 154 | 155 | HTML)), 156 | 157 | 158 | PHP, 159 | ); 160 | } 161 | 162 | /** 163 | * Render a text node. 164 | */ 165 | protected function renderText(Node $node): string 166 | { 167 | return e(Str::squish($node->value)); 168 | } 169 | 170 | /** 171 | * Retrieve the node's title information. 172 | * 173 | * @return array{ 0: Node|null, 1: int } 174 | */ 175 | public function info(Node $title): array 176 | { 177 | // Once we hit the function reference section the rules change, so we 178 | // will check if we are in the function reference or not. 179 | $isFunctionRef = (bool) $title->parent('set')?->hasId('funcref'); 180 | 181 | // When in the function reference, we modify each title within the 182 | // function reference to be one level deeper. 183 | if ($isFunctionRef) { 184 | $this->levelModifier = 1; 185 | } 186 | 187 | // The FAQs should then revert and use no level modifier. 188 | if ($title->parent('book')?->hasId('faq')) { 189 | $this->levelModifier = 0; 190 | } 191 | 192 | return match (true) { 193 | $isFunctionRef => [$title->parent('set'), 1], 194 | $title->hasParent('book.set') => [$title->parent('book'), 1 + $this->levelModifier], 195 | $title->hasParent('chapter.book.set.set.set') => [$title->parent('chapter'), 2 + $this->levelModifier], 196 | $title->hasParent('chapter.book.set') => [$title->parent('chapter'), 2 + $this->levelModifier], 197 | $title->hasParent('info.chapter.book.set') => [$title->parent('info.chapter'), 2 + $this->levelModifier], 198 | $title->hasParent('info.legalnotice.info.set') => [$title->parent('info.legalnotice'), 1 + $this->levelModifier], 199 | $title->hasParent('info.preface.book.set') => [$title->parent('info.preface'), 1 + $this->levelModifier], 200 | $title->hasParent('info.section.chapter.book.set') => [$title->parent('info.section'), 3 + $this->levelModifier], 201 | $title->hasParent('preface.book.set.set.set') => [$title->parent('preface'), 2 + $this->levelModifier], 202 | $title->hasParent('sect1.chapter.book.set') => [$title->parent('sect1'), 3 + $this->levelModifier], 203 | $title->hasParent('section.chapter.book.set.set.set') && $title->expectParent('section')->hasId() => [$title->parent('section'), 3 + $this->levelModifier], 204 | $title->hasParent('set') => [$title->parent('set'), 1 + $this->levelModifier], 205 | default => [null, 0], 206 | }; 207 | } 208 | 209 | /** 210 | * Retrieve all titles from the index. 211 | * 212 | * @return Collection 213 | */ 214 | public function all(): Collection 215 | { 216 | return $this->allCache ??= collect(require $this->stream->path); // @phpstan-ignore argument.templateType, argument.templateType 217 | } 218 | 219 | /** 220 | * Find the title based on it's ID. 221 | */ 222 | public function find(string $id): Title 223 | { 224 | $title = $this->findMany(collect([$id]))->first(); 225 | 226 | if ($title === null) { 227 | throw new RuntimeException("Could not find title with id [{$id}]."); 228 | } 229 | 230 | return $title; 231 | } 232 | 233 | /** 234 | * Find many titles based on their ID. 235 | * 236 | * @param Collection $ids 237 | * @return Collection 238 | */ 239 | public function findMany(Collection $ids): Collection 240 | { 241 | return $this->all()->only($ids); 242 | } 243 | 244 | /** 245 | * Retrieve the title heirachy. 246 | * 247 | * @return Collection 248 | */ 249 | public function heirachy(): Collection 250 | { 251 | return $this->all() 252 | ->reduce(function (Collection $result, Title $title) { 253 | $level = 1; 254 | $children = $result; 255 | 256 | while ($level < $title->level) { 257 | $children = $children->reverse()->first()->children; 258 | 259 | $level++; 260 | } 261 | 262 | $children->push(new Title( 263 | id: $title->id, 264 | lineage: $title->lineage, 265 | level: $level, 266 | html: $result->isEmpty() ? new HtmlString('Home') : $title->html, 267 | )); 268 | 269 | return $result; 270 | }, collect([])); 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | import defaultTheme from 'tailwindcss/defaultTheme' 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: [ 6 | "./build/output/*.html", 7 | ], 8 | darkMode: 'selector', 9 | theme: { 10 | extend: { 11 | fontFamily: { 12 | mono: ['"Roboto Mono"', ...defaultTheme.fontFamily.mono], 13 | }, 14 | colors: { 15 | violet: { 16 | '25': '#f8f5ff', 17 | } 18 | } 19 | }, 20 | }, 21 | plugins: [], 22 | } 23 | 24 | -------------------------------------------------------------------------------- /theme.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "light", 3 | "colors": { 4 | "activityBar.activeBorder": "#6d6d6d", 5 | "activityBar.background": "#24292e", 6 | "activityBar.foreground": "#fafbfc", 7 | "activityBarBadge.background": "#d64926", 8 | "button.background": "#5790be", 9 | "editor.background": "#f9f9f9", 10 | "editor.foreground": "#24292e", 11 | "editor.lineHighlightBackground": "#eaeff5", 12 | "editorBracketMatch.background": "#f1f8ff", 13 | "editorBracketMatch.border": "#c8e1ff", 14 | "editorGroup.border": "#cecece", 15 | "editorGroupHeader.tabsBackground": "#fafbfc", 16 | "editorGroupHeader.tabsBorder": "#e1e4e8", 17 | "editorIndentGuide.background": "#eeeeee", 18 | "editorLineNumber.foreground": "#cccccc", 19 | "focusBorder": "#4b4b4b", 20 | "input.border": "#d1d1d1", 21 | "list.activeSelectionBackground": "#5790be", 22 | "list.activeSelectionForeground": "#f0f0f0", 23 | "list.activeSelectionIconForeground": "#f0f0f0", 24 | "list.focusHighlightForeground": "#ffffff", 25 | "list.inactiveSelectionBackground": "#d0e2f0", 26 | "menu.background": "#24292e", 27 | "menu.border": "#494949", 28 | "menu.foreground": "#cccccc", 29 | "menu.selectionBackground": "#9e9e9e", 30 | "menu.separatorBackground": "#494949", 31 | "menubar.selectionBackground": "#494949", 32 | "panel.background": "#f3f3f3", 33 | "scrollbar.shadow": "#cccccc", 34 | "sideBar.background": "#f3f3f3", 35 | "sideBar.border": "#cecece", 36 | "sideBar.foreground": "#586069", 37 | "sideBarSectionHeader.background": "#e6e6e6", 38 | "sideBarSectionHeader.foreground": "#616161", 39 | "sideBarTitle.foreground": "#24292e", 40 | "statusBar.background": "#24292e", 41 | "statusBar.border": "#ff000000", 42 | "statusBar.debuggingBackground": "#d64926", 43 | "statusBar.debuggingBorder": "#ff000000", 44 | "statusBar.debuggingForeground": "#cccccc", 45 | "statusBar.foreground": "#cccccc", 46 | "statusBar.noFolderBackground": "#24292e", 47 | "statusBar.noFolderBorder": "#ff000000", 48 | "statusBar.noFolderForeground": "#cccccc", 49 | "statusBarItem.remoteBackground": "#d64926", 50 | "statusBarItem.remoteForeground": "#ffffff", 51 | "tab.activeBackground": "#ffffff", 52 | "tab.activeBorder": "#e36209", 53 | "tab.border": "#e1e4e8", 54 | "tab.inactiveBackground": "#fafbfc", 55 | "tab.inactiveForeground": "#586069", 56 | "terminal.background": "#f3f3f3", 57 | "terminal.tab.activeBorder": "#00000000", 58 | "titleBar.activeBackground": "#24292e", 59 | "titleBar.activeForeground": "#cccccc", 60 | "titleBar.border": "#ff000000", 61 | "titleBar.inactiveBackground": "#24292e" 62 | }, 63 | "tokenColors": [ 64 | { 65 | "settings": { 66 | "background": "transparent", 67 | "foreground": "#24292eff" 68 | } 69 | }, 70 | { 71 | "scope": [ 72 | "keyword.operator.accessor", 73 | "meta.group.braces.round.function.arguments", 74 | "meta.template.expression", 75 | "markup.fenced_code meta.embedded.block" 76 | ], 77 | "settings": { 78 | "foreground": "#24292eff" 79 | } 80 | }, 81 | { 82 | "scope": "emphasis", 83 | "settings": { 84 | "fontStyle": "italic" 85 | } 86 | }, 87 | { 88 | "scope": [ 89 | "strong", 90 | "markup.heading.markdown", 91 | "markup.bold.markdown" 92 | ], 93 | "settings": { 94 | "fontStyle": "bold" 95 | } 96 | }, 97 | { 98 | "scope": [ 99 | "markup.italic.markdown" 100 | ], 101 | "settings": { 102 | "fontStyle": "italic" 103 | } 104 | }, 105 | { 106 | "scope": "meta.link.inline.markdown", 107 | "settings": { 108 | "fontStyle": "underline", 109 | "foreground": "#005cc5" 110 | } 111 | }, 112 | { 113 | "scope": [ 114 | "comment", 115 | "markup.fenced_code", 116 | "markup.inline" 117 | ], 118 | "settings": { 119 | "foreground": "#6a737d" 120 | } 121 | }, 122 | { 123 | "scope": "string", 124 | "settings": { 125 | "foreground": "#032f62" 126 | } 127 | }, 128 | { 129 | "scope": [ 130 | "constant.numeric", 131 | "constant.language", 132 | "variable.language.this", 133 | "variable.other.class", 134 | "variable.other.constant", 135 | "meta.property-name", 136 | "meta.property-value", 137 | "support" 138 | ], 139 | "settings": { 140 | "foreground": "#005cc5" 141 | } 142 | }, 143 | { 144 | "scope": [ 145 | "keyword", 146 | "storage.modifier", 147 | "storage.type", 148 | "storage.control.clojure", 149 | "entity.name.function.clojure", 150 | "support.function.node", 151 | "support.type.property-name.json", 152 | "punctuation.separator.key-value", 153 | "punctuation.definition.template-expression" 154 | ], 155 | "settings": { 156 | "foreground": "#d73a49" 157 | } 158 | }, 159 | { 160 | "scope": "variable.parameter.function", 161 | "settings": { 162 | "foreground": "#E27F2D" 163 | } 164 | }, 165 | { 166 | "scope": [ 167 | "entity.name.type", 168 | "entity.other.inherited-class", 169 | "meta.function-call", 170 | "meta.instance.constructor", 171 | "entity.other.attribute-name", 172 | "entity.name.function", 173 | "constant.keyword.clojure" 174 | ], 175 | "settings": { 176 | "foreground": "#6f42c1" 177 | } 178 | }, 179 | { 180 | "scope": [ 181 | "entity.name.tag", 182 | "string.quoted", 183 | "string.regexp", 184 | "string.interpolated", 185 | "string.template", 186 | "keyword.other.template" 187 | ], 188 | "settings": { 189 | "foreground": "#22863a" 190 | } 191 | }, 192 | { 193 | "scope": "token.info-token", 194 | "settings": { 195 | "foreground": "#316bcd" 196 | } 197 | }, 198 | { 199 | "scope": "token.warn-token", 200 | "settings": { 201 | "foreground": "#cd9731" 202 | } 203 | }, 204 | { 205 | "scope": "token.error-token", 206 | "settings": { 207 | "foreground": "#cd3131" 208 | } 209 | }, 210 | { 211 | "scope": "token.debug-token", 212 | "settings": { 213 | "foreground": "#800080" 214 | } 215 | } 216 | ] 217 | } 218 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('vite').UserConfig} */ 2 | export default { 3 | build: { 4 | outDir: "build/output", 5 | emptyOutDir: false, 6 | rollupOptions: { 7 | input: ["resources/script.js", "resources/style.css"], 8 | output: { 9 | assetFileNames: "[name][extname]", 10 | entryFileNames: "[name].js", 11 | }, 12 | }, 13 | }, 14 | } 15 | --------------------------------------------------------------------------------