Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/treetrek.git
M Config.php
22
class Config {
33
  public const SITE_TITLE    = "Dave Jarvis' Repositories";
4
  private const REPOS_SUBDIR = '/repos';
4
  private const REPOS_DIR = '/repos';
5
  private const ORDER_FILE   = '/order.txt';
56
  private const LOG_FILE     = '/error.log';
67
...
1415
1516
  public function createRouter() {
16
    $path = $this->getHomeDirectory() . self::REPOS_SUBDIR;
17
    $repos = $this->getHomeDirectory() . self::REPOS_DIR;
1718
18
    return new Router( $path );
19
    return new Router( $repos, $repos . self::ORDER_FILE );
1920
  }
2021
M INSTALL.md
2424
   export OWNER=username
2525
   export WEBDIR=/var/www
26
   export WEBOWNER=www-data
2627
   ```
2728
1. Download code:
2829
   ```bash
2930
   mkdir -p /var/www/${REPO}
30
   cd /var/www/${REPO}
31
   cd ${WEBDIR}/${REPO}
3132
   git clone https://repo.autonoma.ca/repo/treetrek
32
   sudo usermod -aG ${OWNER} www-data
33
   sudo usermod -aG ${OWNER} ${WEBOWNER}
3334
   ```
3435
1. Edit `${WEBDIR}/${REPO}/Config.php`.
3536
1. Set `SITE_TITLE`.
36
1. Set `REPOS_SUBDIR`.
37
1. Set `REPOS_DIR`.
3738
1. Save the file.
38
1. Edit `${WEBDIR}/${REPO}/order.txt`
39
1. Add repositories you want to come first, hide using a hyphen prefix.
39
1. Edit `<REPOS_DIR>/order.txt`
40
1. List repositories in the order they must appear.
41
1. Hide repositories using a hyphen prefix.
42
1. Save the file.
4043
1. Edit nginx configuration file.
4144
1. Add routing and security rules:
...
7275
   }
7376
   ```
77
1. Save the file.
7478
1. Apply changes:
7579
   ```bash
M README.md
1
# Git Repository Viewer
1
# TreeTrek
22
3
TreeTrek is a free, read-only, open-source, dependency- and authentication-free
4
PHP application for browsing raw Git repositories in either a self-hosted or
5
shared host environment. It functions entirely through direct filesystem access
6
without invoking system commands.
3
TreeTrek is a free, open-source, responsive, optimized, authentication- and
4
dependency-free‡ PHP web application for browsing raw Git repositories in
5
either a self-hosted or shared host environment. It is a human-guided,
6
AI-implemented, greenfield implementation having zero command execution
7
functions.
78
8
## Core Git Implementation
9
## Git implementation
910
1011
The engine implements low-level Git protocols to navigate and extract data from
...
1920
read-only cloning via standard Git clients.
2021
21
## Content and Visualization
22
## User interface
2223
2324
The user interface provides developer-focused tools for inspecting history
2425
and repository content.
2526
2627
* **LCS Diff Engine**: Includes a Longest Common Subsequence (LCS) algorithm
2728
to display line-level changes.
2829
* **Syntax Highlighting**: Uses a regex-based engine with rules for common
29
programming languages.
30
programming languages and configuration file syntaxes.
3031
* **Multimedia Rendering**: Detects media types to natively render
3132
images, video, and audio files in the browser.
3233
* **Breadcrumb Navigation**: Features a dynamic path trail and repository
3334
selector for seamless project switching.
3435
* **Streamed Content**: Streams object data in chunks to handle large files
3536
without exceeding memory limits.
3637
37
## Curation and Architecture
38
## Design
3839
39
Project organization and system behavior are managed through flat-file
40
Project organization and system behaviour are managed through flat-file
4041
configurations and an object-oriented design. This structure separates
4142
repository logic from the presentation layer.
4243
4344
* **Repository Curation**: Uses an `order.txt` file to define the
4445
display sequence and visibility of projects.
4546
* **Project Exclusion**: Allows hiding specific repositories by
4647
prefixing their names with a hyphen in the `order.txt` config.
4748
* **Stateless Routing**: Maps clean URIs directly to specialized page
4849
controllers for consistent navigation.
50
51
---
52
53
‡Uses FontAwesome.
4954
5055
M git/CompressionStream.php
99
}
1010
11
class ZlibExtractorStream implements CompressionStream {
11
class ZlibDeflatorStream implements CompressionStream {
1212
  public function stream(
1313
    StreamReader $stream,
M git/Git.php
9393
9494
  public function getObjectSize( string $sha, string $path = '' ): int {
95
    return $path !== ''
96
      ? ( $this->resolvePath(
97
            $this->getTreeSha( $this->resolve( $sha ) ),
98
            $path
99
          )['sha'] ?? '' ) !== ''
100
        ? $this->packs->getSize( $this->resolvePath( $this->getTreeSha( $this->resolve( $sha ) ), $path )['sha'] )
101
          ?: $this->loose->getSize( $this->resolvePath( $this->getTreeSha( $this->resolve( $sha ) ), $path )['sha'] )
102
        : 0
103
      : ( $sha !== ''
104
        ? $this->packs->getSize( $sha ) ?: $this->loose->getSize( $sha )
105
        : 0 );
95
    $target = $sha;
96
97
    if( $path !== '' ) {
98
      $target = $this->resolvePath(
99
        $this->getTreeSha( $this->resolve( $sha ) ),
100
        $path
101
      )['sha'] ?? '';
102
    }
103
104
    return $target !== ''
105
      ? $this->packs->getSize( $target )
106
        ?: $this->loose->getSize( $target )
107
      : 0;
106108
  }
107109
M git/GitPacks.php
7070
        int $offset
7171
      ) use ( &$result ): void {
72
        $context = $this->createContext(
73
          $packFile, $offset, 0
74
        );
75
        $meta    = $this->reader->getEntryMeta(
76
          $context
72
        $result           = $this->reader->getEntryMeta(
73
          $this->createContext( $packFile, $offset, 0 )
7774
        );
78
79
        $result           = $meta;
8075
        $result['file']   = $packFile;
8176
        $result['offset'] = $offset;
8277
      }
8378
    );
8479
8580
    return $result;
8681
  }
8782
88
  public function peek(
89
    string $sha,
90
    int $len = 12
91
  ): string {
83
  public function peek( string $sha, int $len = 12 ): string {
9284
    $result = '';
9385
9486
    $this->locate(
9587
      $sha,
9688
      function(
9789
        string $packFile,
9890
        int $offset
9991
      ) use ( &$result, $len ): void {
100
        $context = $this->createContext(
101
          $packFile, $offset, 0
102
        );
103
        $result  = $this->reader->read(
104
          $context,
92
        $result = $this->reader->read(
93
          $this->createContext( $packFile, $offset, 0 ),
10594
          $len,
106
          function(
107
            string $baseSha,
108
            int $cap
109
          ): string {
95
          function( string $baseSha, int $cap ): string {
11096
            return $this->peek( $baseSha, $cap );
11197
          }
...
131117
        $size    = $this->reader->getSize( $context );
132118
133
        if( $size <= self::MAX_RAM ) {
134
          $result = $this->reader->read(
135
            $context,
136
            0,
137
            function(
138
              string $baseSha,
139
              int $cap
140
            ): string {
141
              return $cap > 0
142
                ? $this->peek( $baseSha, $cap )
143
                : $this->read( $baseSha );
144
            }
145
          );
146
        }
119
        $result = $size <= self::MAX_RAM
120
          ? $this->reader->read(
121
              $context,
122
              0,
123
              function( string $baseSha, int $cap ): string {
124
                return $cap > 0
125
                  ? $this->peek( $baseSha, $cap )
126
                  : $this->read( $baseSha );
127
              }
128
            )
129
          : $result;
147130
      }
148131
    );
...
157140
    $result = false;
158141
159
    foreach(
160
      $this->streamGenerator( $sha ) as $chunk
161
    ) {
142
    foreach( $this->streamGenerator( $sha ) as $chunk ) {
162143
      $callback( $chunk );
163144
164145
      $result = true;
165146
    }
166147
167148
    return $result;
168149
  }
169150
170
  public function streamGenerator(
171
    string $sha
172
  ): Generator {
151
  public function streamGenerator( string $sha ): Generator {
173152
    yield from $this->streamShaGenerator( $sha, 0 );
174153
  }
175154
176
  public function streamRawCompressed(
177
    string $sha
178
  ): Generator {
155
  public function streamRawCompressed( string $sha ): Generator {
179156
    $found = false;
180157
    $file  = '';
...
192169
      }
193170
    );
194
195
    if( $found ) {
196
      $context = $this->createContext(
197
        $file, $off, 0
198
      );
199171
200
      yield from $this->reader->streamRawCompressed(
201
        $context
202
      );
203
    }
172
    yield from $found
173
      ? $this->reader->streamRawCompressed(
174
          $this->createContext( $file, $off, 0 )
175
        )
176
      : [];
204177
  }
205178
206
  public function streamRawDelta(
207
    string $sha
208
  ): Generator {
179
  public function streamRawDelta( string $sha ): Generator {
209180
    $found = false;
210181
    $file  = '';
...
222193
      }
223194
    );
224
225
    if( $found ) {
226
      $context = $this->createContext(
227
        $file, $off, 0
228
      );
229195
230
      yield from $this->reader->streamRawDelta(
231
        $context
232
      );
233
    }
196
    yield from $found
197
      ? $this->reader->streamRawDelta(
198
          $this->createContext( $file, $off, 0 )
199
        )
200
      : [];
234201
  }
235202
...
253220
      }
254221
    );
255
256
    if( $found ) {
257
      $context = $this->createContext(
258
        $file, $off, $depth
259
      );
260222
261
      yield from $this->reader->streamEntryGenerator(
262
        $context
263
      );
264
    }
223
    yield from $found
224
      ? $this->reader->streamEntryGenerator(
225
          $this->createContext( $file, $off, $depth )
226
        )
227
      : [];
265228
  }
266229
...
274237
        int $offset
275238
      ) use ( &$result ): void {
276
        $context = $this->createContext(
277
          $packFile, $offset, 0
239
        $result = $this->reader->getSize(
240
          $this->createContext( $packFile, $offset, 0 )
278241
        );
279
        $result  = $this->reader->getSize( $context );
280242
      }
281243
    );
M git/PackEntryReader.php
1212
  private const MAX_CACHE    = 1024;
1313
14
  private DeltaDecoder $decoder;
15
  private array        $cache;
16
17
  public function __construct( DeltaDecoder $decoder ) {
18
    $this->decoder = $decoder;
19
    $this->cache   = [];
20
  }
21
22
  public function getEntryMeta( PackContext $context ): array {
23
    return $context->computeArray(
24
      function( StreamReader $stream, int $offset ): array {
25
        $packStream = new GitPackStream( $stream );
26
        $packStream->seek( $offset );
27
        $hdr = $packStream->readVarInt();
28
29
        return [
30
          'type'       => $hdr['type'],
31
          'size'       => $hdr['size'],
32
          'baseOffset' => $hdr['type'] === 6
33
            ? $offset - $packStream->readOffsetDelta()
34
            : 0,
35
          'baseSha'    => $hdr['type'] === 7
36
            ? \bin2hex( $packStream->read( 20 ) )
37
            : ''
38
        ];
39
      },
40
      [ 'type' => 0, 'size' => 0 ]
41
    );
42
  }
43
44
  public function getSize( PackContext $context ): int {
45
    return $context->computeIntDedicated(
46
      function( StreamReader $stream, int $offset ): int {
47
        $packStream = new GitPackStream( $stream );
48
        $packStream->seek( $offset );
49
        $hdr = $packStream->readVarInt();
50
51
        return $hdr['type'] === 6 || $hdr['type'] === 7
52
          ? $this->decoder->readDeltaTargetSize(
53
              $stream, $hdr['type']
54
            )
55
          : $hdr['size'];
56
      },
57
      0
58
    );
59
  }
60
61
  public function read(
62
    PackContext $context,
63
    int $cap,
64
    callable $readShaBaseFn
65
  ): string {
66
    return $context->computeStringDedicated(
67
      function(
68
        StreamReader $s,
69
        int $o
70
      ) use ( $cap, $readShaBaseFn ): string {
71
        return $this->readWithStream(
72
          new GitPackStream( $s ),
73
          $o,
74
          $cap,
75
          $readShaBaseFn
76
        );
77
      },
78
      ''
79
    );
80
  }
81
82
  private function readWithStream(
83
    GitPackStream $stream,
84
    int $offset,
85
    int $cap,
86
    callable $readShaBaseFn
87
  ): string {
88
    $stream->seek( $offset );
89
    $hdr  = $stream->readVarInt();
90
    $type = $hdr['type'];
91
92
    $result = isset( $this->cache[$offset] )
93
      ? ( $cap > 0 && \strlen( $this->cache[$offset] ) > $cap
94
          ? \substr( $this->cache[$offset], 0, $cap )
95
          : $this->cache[$offset] )
96
      : ( $type === 6
97
          ? $this->readOffsetDeltaContent(
98
              $stream, $offset, $cap, $readShaBaseFn
99
            )
100
          : ( $type === 7
101
              ? $this->readRefDeltaContent(
102
                  $stream, $cap, $readShaBaseFn
103
                )
104
              : $this->inflate( $stream, $cap ) ) );
105
106
    if( $cap === 0 && !isset( $this->cache[$offset] ) ) {
107
      $this->cache[$offset] = $result;
108
109
      if( \count( $this->cache ) > self::MAX_CACHE ) {
110
        unset(
111
          $this->cache[\array_key_first( $this->cache )]
112
        );
113
      }
114
    }
115
116
    return $result;
117
  }
118
119
  private function readOffsetDeltaContent(
120
    GitPackStream $stream,
121
    int $offset,
122
    int $cap,
123
    callable $readShaBaseFn
124
  ): string {
125
    $neg   = $stream->readOffsetDelta();
126
    $cur   = $stream->tell();
127
    $bData = $this->readWithStream(
128
      $stream,
129
      $offset - $neg,
130
      $cap,
131
      $readShaBaseFn
132
    );
133
134
    $stream->seek( $cur );
135
136
    return $this->decoder->apply(
137
      $bData,
138
      $this->inflate( $stream ),
139
      $cap
140
    );
141
  }
142
143
  private function readRefDeltaContent(
144
    GitPackStream $stream,
145
    int $cap,
146
    callable $readShaBaseFn
147
  ): string {
148
    $sha = \bin2hex( $stream->read( 20 ) );
149
    $cur = $stream->tell();
150
    $bas = $readShaBaseFn( $sha, $cap );
151
152
    $stream->seek( $cur );
153
154
    return $this->decoder->apply(
155
      $bas,
156
      $this->inflate( $stream ),
157
      $cap
158
    );
159
  }
160
161
  public function streamRawCompressed(
162
    PackContext $context
163
  ): Generator {
164
    yield from $context->streamGenerator(
165
      function( StreamReader $stream, int $offset ): Generator {
166
        $packStream = new GitPackStream( $stream );
167
        $packStream->seek( $offset );
168
        $hdr = $packStream->readVarInt();
169
170
        yield from $hdr['type'] !== 6 && $hdr['type'] !== 7
171
          ? (new ZlibExtractorStream())->stream( $stream )
172
          : [];
173
      }
174
    );
175
  }
176
177
  public function streamRawDelta( PackContext $context ): Generator {
178
    yield from $context->streamGenerator(
179
      function( StreamReader $stream, int $offset ): Generator {
180
        $packStream = new GitPackStream( $stream );
181
        $packStream->seek( $offset );
182
        $hdr = $packStream->readVarInt();
183
184
        if( $hdr['type'] === 6 ) {
185
          $packStream->readOffsetDelta();
186
        } elseif( $hdr['type'] === 7 ) {
187
          $packStream->read( 20 );
188
        }
189
190
        yield from (new ZlibExtractorStream())->stream( $stream );
191
      }
192
    );
193
  }
194
195
  public function streamEntryGenerator(
196
    PackContext $context
197
  ): Generator {
198
    yield from $context->streamGeneratorDedicated(
199
      function(
200
        StreamReader $stream,
201
        int $offset
202
      ) use ( $context ): Generator {
203
        $packStream = new GitPackStream( $stream );
204
        $packStream->seek( $offset );
205
        $hdr = $packStream->readVarInt();
206
207
        yield from $hdr['type'] === 6 || $hdr['type'] === 7
208
          ? $this->streamDeltaObjectGenerator(
209
              $packStream,
210
              $context,
211
              $hdr['type'],
212
              $offset
213
            )
214
          : (new ZlibInflaterStream())->stream( $stream );
215
      }
216
    );
217
  }
218
219
  private function streamDeltaObjectGenerator(
220
    GitPackStream $stream,
221
    PackContext $context,
222
    int $type,
223
    int $offset
224
  ): Generator {
225
    yield from $context->isWithinDepth( self::MAX_DEPTH )
226
      ? ( $type === 6
227
          ? $this->processOffsetDelta(
228
              $stream, $context, $offset
229
            )
230
          : $this->processRefDelta( $stream, $context ) )
231
      : [];
232
  }
233
234
  private function readSizeWithStream(
235
    GitPackStream $stream,
236
    int $offset
237
  ): int {
238
    $cur = $stream->tell();
239
    $stream->seek( $offset );
240
    $hdr = $stream->readVarInt();
241
242
    $result = isset( $this->cache[$offset] )
243
      ? \strlen( $this->cache[$offset] )
244
      : ( $hdr['type'] === 6 || $hdr['type'] === 7
245
          ? $this->decoder->readDeltaTargetSize(
246
              $stream, $hdr['type']
247
            )
248
          : $hdr['size'] );
249
250
    if( !isset( $this->cache[$offset] ) ) {
251
      $stream->seek( $cur );
252
    }
253
254
    return $result;
255
  }
256
257
  private function processOffsetDelta(
258
    GitPackStream $stream,
259
    PackContext $context,
260
    int $offset
261
  ): Generator {
262
    $neg     = $stream->readOffsetDelta();
263
    $cur     = $stream->tell();
264
    $baseOff = $offset - $neg;
265
266
    $baseSrc = isset( $this->cache[$baseOff] )
267
      ? $this->cache[$baseOff]
268
      : ( $this->readSizeWithStream( $stream, $baseOff )
269
          <= self::MAX_BASE_RAM
270
          ? $this->readWithStream(
271
              $stream,
272
              $baseOff,
273
              0,
274
              function(
275
                string $sha,
276
                int $cap
277
              ) use ( $context ): string {
278
                return $this->resolveBaseSha(
279
                  $sha, $cap, $context
280
                );
281
              }
282
            )
283
          : $this->collectBase(
284
              $this->streamEntryGenerator(
285
                $context->deriveOffsetContext( $neg )
286
              )
287
            ) );
288
289
    $stream->seek( $cur );
290
291
    yield from $this->decoder->applyStreamGenerator(
292
      $stream, $baseSrc
293
    );
294
  }
295
296
  private function processRefDelta(
297
    GitPackStream $stream,
298
    PackContext $context
299
  ): Generator {
300
    $baseSha = \bin2hex( $stream->read( 20 ) );
301
    $cur     = $stream->tell();
302
    $size    = $context->resolveBaseSize( $baseSha );
303
304
    $baseSrc = $size <= self::MAX_BASE_RAM
305
      ? $this->resolveBaseSha( $baseSha, 0, $context )
306
      : $this->collectBase(
307
          $context->resolveBaseStream( $baseSha )
308
        );
309
310
    $stream->seek( $cur );
311
312
    yield from $this->decoder->applyStreamGenerator(
313
      $stream, $baseSrc
314
    );
315
  }
316
317
  private function collectBase(
318
    iterable $chunks
319
  ): BufferedReader|string {
320
    $parts = [];
321
    $total = 0;
322
    $tmp   = false;
323
324
    foreach( $chunks as $chunk ) {
325
      $total += \strlen( $chunk );
326
327
      if( $tmp instanceof BufferedReader ) {
328
        $tmp->write( $chunk );
329
      } elseif( $total > self::MAX_BASE_RAM ) {
330
        $tmp = new BufferedReader(
331
          'php://temp/maxmemory:65536', 'w+b'
332
        );
333
334
        foreach( $parts as $part ) {
335
          $tmp->write( $part );
336
        }
337
338
        $tmp->write( $chunk );
339
        $parts = [];
340
      } else {
341
        $parts[] = $chunk;
342
      }
343
    }
344
345
    if( $tmp instanceof BufferedReader ) {
346
      $tmp->rewind();
347
    }
348
349
    return $tmp === false ? \implode( '', $parts ) : $tmp;
350
  }
351
352
  private function resolveBaseSha(
353
    string $sha,
354
    int $cap,
355
    PackContext $context
356
  ): string {
357
    $chunks = [];
358
359
    foreach(
360
      $context->resolveBaseStream( $sha ) as $chunk
361
    ) {
362
      $chunks[] = $chunk;
363
    }
364
365
    $result = \implode( '', $chunks );
366
367
    return $cap > 0 && \strlen( $result ) > $cap
368
      ? \substr( $result, 0, $cap )
369
      : $result;
370
  }
371
372
  private function inflate(
373
    StreamReader $stream,
374
    int $cap = 0
375
  ): string {
376
    $inflater = new ZlibInflaterStream();
377
    $chunks   = [];
378
    $len      = 0;
379
380
    foreach( $inflater->stream( $stream ) as $data ) {
14
  private DeltaDecoder       $decoder;
15
  private ZlibDeflatorStream $deflator;
16
  private ZlibInflaterStream $inflater;
17
  private array              $cache;
18
  private int                $cacheSize;
19
20
  public function __construct( DeltaDecoder $decoder ) {
21
    $this->decoder   = $decoder;
22
    $this->deflator  = new ZlibDeflatorStream();
23
    $this->inflater  = new ZlibInflaterStream();
24
    $this->cache     = [];
25
    $this->cacheSize = 0;
26
  }
27
28
  public function getEntryMeta( PackContext $context ): array {
29
    return $context->computeArray(
30
      function( StreamReader $stream, int $offset ): array {
31
        $packStream = new GitPackStream( $stream );
32
        $packStream->seek( $offset );
33
        $hdr = $packStream->readVarInt();
34
35
        return [
36
          'type'       => $hdr['type'],
37
          'size'       => $hdr['size'],
38
          'baseOffset' => $hdr['type'] === 6
39
            ? $offset - $packStream->readOffsetDelta()
40
            : 0,
41
          'baseSha'    => $hdr['type'] === 7
42
            ? \bin2hex( $packStream->read( 20 ) )
43
            : ''
44
        ];
45
      },
46
      [ 'type' => 0, 'size' => 0 ]
47
    );
48
  }
49
50
  public function getSize( PackContext $context ): int {
51
    return $context->computeIntDedicated(
52
      function( StreamReader $stream, int $offset ): int {
53
        $packStream = new GitPackStream( $stream );
54
        $packStream->seek( $offset );
55
        $hdr = $packStream->readVarInt();
56
57
        return $hdr['type'] === 6 || $hdr['type'] === 7
58
          ? $this->decoder->readDeltaTargetSize(
59
              $stream, $hdr['type']
60
            )
61
          : $hdr['size'];
62
      },
63
      0
64
    );
65
  }
66
67
  public function read(
68
    PackContext $context,
69
    int $cap,
70
    callable $readShaBaseFn
71
  ): string {
72
    return $context->computeStringDedicated(
73
      function(
74
        StreamReader $s,
75
        int $o
76
      ) use ( $cap, $readShaBaseFn ): string {
77
        return $this->readWithStream(
78
          new GitPackStream( $s ), $o, $cap, $readShaBaseFn
79
        );
80
      },
81
      ''
82
    );
83
  }
84
85
  private function readWithStream(
86
    GitPackStream $stream,
87
    int $offset,
88
    int $cap,
89
    callable $readShaBaseFn
90
  ): string {
91
    $stream->seek( $offset );
92
    $hdr  = $stream->readVarInt();
93
    $type = $hdr['type'];
94
95
    $result = isset( $this->cache[$offset] )
96
      ? ( $cap > 0 && \strlen( $this->cache[$offset] ) > $cap
97
          ? \substr( $this->cache[$offset], 0, $cap )
98
          : $this->cache[$offset] )
99
      : ( $type === 6
100
          ? $this->readOffsetDeltaContent(
101
              $stream, $offset, $cap, $readShaBaseFn
102
            )
103
          : ( $type === 7
104
              ? $this->readRefDeltaContent(
105
                  $stream, $cap, $readShaBaseFn
106
                )
107
              : $this->inflate( $stream, $cap ) ) );
108
109
    if( $cap === 0 && !isset( $this->cache[$offset] ) ) {
110
      $this->cache[$offset] = $result;
111
      $this->cacheSize++;
112
113
      if( $this->cacheSize > self::MAX_CACHE ) {
114
        unset( $this->cache[\array_key_first( $this->cache )] );
115
        $this->cacheSize--;
116
      }
117
    }
118
119
    return $result;
120
  }
121
122
  private function readOffsetDeltaContent(
123
    GitPackStream $stream,
124
    int $offset,
125
    int $cap,
126
    callable $readShaBaseFn
127
  ): string {
128
    $neg   = $stream->readOffsetDelta();
129
    $cur   = $stream->tell();
130
    $bData = $this->readWithStream(
131
      $stream, $offset - $neg, $cap, $readShaBaseFn
132
    );
133
134
    $stream->seek( $cur );
135
136
    return $this->decoder->apply(
137
      $bData,
138
      $this->inflate( $stream ),
139
      $cap
140
    );
141
  }
142
143
  private function readRefDeltaContent(
144
    GitPackStream $stream,
145
    int $cap,
146
    callable $readShaBaseFn
147
  ): string {
148
    $sha = \bin2hex( $stream->read( 20 ) );
149
    $cur = $stream->tell();
150
    $bas = $readShaBaseFn( $sha, $cap );
151
152
    $stream->seek( $cur );
153
154
    return $this->decoder->apply(
155
      $bas,
156
      $this->inflate( $stream ),
157
      $cap
158
    );
159
  }
160
161
  public function streamRawCompressed(
162
    PackContext $context
163
  ): Generator {
164
    yield from $context->streamGenerator(
165
      function( StreamReader $stream, int $offset ): Generator {
166
        $packStream = new GitPackStream( $stream );
167
        $packStream->seek( $offset );
168
        $hdr = $packStream->readVarInt();
169
170
        yield from $hdr['type'] !== 6 && $hdr['type'] !== 7
171
          ? $this->deflator->stream( $stream )
172
          : [];
173
      }
174
    );
175
  }
176
177
  public function streamRawDelta( PackContext $context ): Generator {
178
    yield from $context->streamGenerator(
179
      function( StreamReader $stream, int $offset ): Generator {
180
        $packStream = new GitPackStream( $stream );
181
        $packStream->seek( $offset );
182
        $hdr = $packStream->readVarInt();
183
184
        if( $hdr['type'] === 6 ) {
185
          $packStream->readOffsetDelta();
186
        } elseif( $hdr['type'] === 7 ) {
187
          $packStream->read( 20 );
188
        }
189
190
        yield from $this->deflator->stream( $stream );
191
      }
192
    );
193
  }
194
195
  public function streamEntryGenerator(
196
    PackContext $context
197
  ): Generator {
198
    yield from $context->streamGeneratorDedicated(
199
      function(
200
        StreamReader $stream,
201
        int $offset
202
      ) use ( $context ): Generator {
203
        $packStream = new GitPackStream( $stream );
204
        $packStream->seek( $offset );
205
        $hdr = $packStream->readVarInt();
206
207
        yield from $hdr['type'] === 6 || $hdr['type'] === 7
208
          ? $this->streamDeltaObjectGenerator(
209
              $packStream, $context, $hdr['type'], $offset
210
            )
211
          : $this->inflater->stream( $stream );
212
      }
213
    );
214
  }
215
216
  private function streamDeltaObjectGenerator(
217
    GitPackStream $stream,
218
    PackContext $context,
219
    int $type,
220
    int $offset
221
  ): Generator {
222
    yield from $context->isWithinDepth( self::MAX_DEPTH )
223
      ? ( $type === 6
224
          ? $this->processOffsetDelta( $stream, $context, $offset )
225
          : $this->processRefDelta( $stream, $context ) )
226
      : [];
227
  }
228
229
  private function readSizeWithStream(
230
    GitPackStream $stream,
231
    int $offset
232
  ): int {
233
    $cur = $stream->tell();
234
    $stream->seek( $offset );
235
    $hdr = $stream->readVarInt();
236
237
    $result = isset( $this->cache[$offset] )
238
      ? \strlen( $this->cache[$offset] )
239
      : ( $hdr['type'] === 6 || $hdr['type'] === 7
240
          ? $this->decoder->readDeltaTargetSize(
241
              $stream, $hdr['type']
242
            )
243
          : $hdr['size'] );
244
245
    if( !isset( $this->cache[$offset] ) ) {
246
      $stream->seek( $cur );
247
    }
248
249
    return $result;
250
  }
251
252
  private function processOffsetDelta(
253
    GitPackStream $stream,
254
    PackContext $context,
255
    int $offset
256
  ): Generator {
257
    $neg     = $stream->readOffsetDelta();
258
    $cur     = $stream->tell();
259
    $baseOff = $offset - $neg;
260
261
    $baseSrc = isset( $this->cache[$baseOff] )
262
      ? $this->cache[$baseOff]
263
      : ( $this->readSizeWithStream( $stream, $baseOff )
264
          <= self::MAX_BASE_RAM
265
          ? $this->readWithStream(
266
              $stream,
267
              $baseOff,
268
              0,
269
              function( string $sha, int $cap ) use ( $context ): string {
270
                return $this->resolveBaseSha( $sha, $cap, $context );
271
              }
272
            )
273
          : $this->collectBase(
274
              $this->streamEntryGenerator(
275
                $context->deriveOffsetContext( $neg )
276
              )
277
            ) );
278
279
    $stream->seek( $cur );
280
281
    yield from $this->decoder->applyStreamGenerator(
282
      $stream, $baseSrc
283
    );
284
  }
285
286
  private function processRefDelta(
287
    GitPackStream $stream,
288
    PackContext $context
289
  ): Generator {
290
    $baseSha = \bin2hex( $stream->read( 20 ) );
291
    $cur     = $stream->tell();
292
    $size    = $context->resolveBaseSize( $baseSha );
293
294
    $baseSrc = $size <= self::MAX_BASE_RAM
295
      ? $this->resolveBaseSha( $baseSha, 0, $context )
296
      : $this->collectBase(
297
          $context->resolveBaseStream( $baseSha )
298
        );
299
300
    $stream->seek( $cur );
301
302
    yield from $this->decoder->applyStreamGenerator(
303
      $stream, $baseSrc
304
    );
305
  }
306
307
  private function collectBase(
308
    iterable $chunks
309
  ): BufferedReader|string {
310
    $parts = [];
311
    $total = 0;
312
    $tmp   = false;
313
314
    foreach( $chunks as $chunk ) {
315
      $total += \strlen( $chunk );
316
317
      if( $tmp instanceof BufferedReader ) {
318
        $tmp->write( $chunk );
319
      } elseif( $total > self::MAX_BASE_RAM ) {
320
        $tmp = new BufferedReader(
321
          'php://temp/maxmemory:65536', 'w+b'
322
        );
323
324
        foreach( $parts as $part ) {
325
          $tmp->write( $part );
326
        }
327
328
        $tmp->write( $chunk );
329
        $parts = [];
330
      } else {
331
        $parts[] = $chunk;
332
      }
333
    }
334
335
    if( $tmp instanceof BufferedReader ) {
336
      $tmp->rewind();
337
    }
338
339
    return $tmp === false ? \implode( '', $parts ) : $tmp;
340
  }
341
342
  private function resolveBaseSha(
343
    string $sha,
344
    int $cap,
345
    PackContext $context
346
  ): string {
347
    $chunks = [];
348
349
    foreach( $context->resolveBaseStream( $sha ) as $chunk ) {
350
      $chunks[] = $chunk;
351
    }
352
353
    $result = \implode( '', $chunks );
354
355
    return $cap > 0 && \strlen( $result ) > $cap
356
      ? \substr( $result, 0, $cap )
357
      : $result;
358
  }
359
360
  private function inflate(
361
    StreamReader $stream,
362
    int $cap = 0
363
  ): string {
364
    $chunks = [];
365
    $len    = 0;
366
367
    foreach( $this->inflater->stream( $stream ) as $data ) {
381368
      $chunks[]  = $data;
382369
      $len      += \strlen( $data );
M git/PackfileWriter.php
3131
      $written[$sha] = $outPos;
3232
      $baseSha       = $entry['baseSha'];
33
34
      $reuse = $baseSha !== ''
33
      $reuse         = $baseSha !== ''
3534
        && isset( $written[$baseSha] );
36
37
      if( $reuse ) {
38
        $hdr  = $this->encodeEntryHeader(
39
          6, $entry['deltaSize']
40
        );
41
        $hdr .= $this->encodeOffsetDelta(
42
          $outPos - $written[$baseSha]
43
        );
4435
45
        \hash_update( $ctx, $hdr );
46
        $outPos += \strlen( $hdr );
47
        yield $hdr;
36
      $hdr = $reuse
37
        ? $this->encodeEntryHeader( 6, $entry['deltaSize'] )
38
          . $this->encodeOffsetDelta( $outPos - $written[$baseSha] )
39
        : $this->encodeEntryHeader(
40
            $entry['logicalType'],
41
            $entry['size'] > 0
42
              ? $entry['size']
43
              : $this->getObjectSize( $sha )
44
          );
4845
49
        foreach(
50
          $this->packs->streamRawDelta(
51
            $sha
52
          ) as $chunk
53
        ) {
54
          \hash_update( $ctx, $chunk );
55
          $outPos += \strlen( $chunk );
56
          yield $chunk;
57
        }
58
      } else {
59
        $size = $this->getObjectSize( $sha );
60
        $hdr  = $this->encodeEntryHeader(
61
          $entry['logicalType'], $size
62
        );
46
      \hash_update( $ctx, $hdr );
47
      $outPos += \strlen( $hdr );
48
      yield $hdr;
6349
64
        \hash_update( $ctx, $hdr );
65
        $outPos += \strlen( $hdr );
66
        yield $hdr;
50
      $stream = $reuse
51
        ? $this->packs->streamRawDelta( $sha )
52
        : $this->streamCompressed( $sha );
6753
68
        foreach(
69
          $this->streamCompressed(
70
            $sha
71
          ) as $chunk
72
        ) {
73
          \hash_update( $ctx, $chunk );
74
          $outPos += \strlen( $chunk );
75
          yield $chunk;
76
        }
54
      foreach( $stream as $chunk ) {
55
        \hash_update( $ctx, $chunk );
56
        $outPos += \strlen( $chunk );
57
        yield $chunk;
7758
      }
7859
    }
7960
8061
    yield \hash_final( $ctx, true );
8162
  }
8263
83
  private function buildEntries(
84
    array $objs
85
  ): array {
64
  private function buildEntries( array $objs ): array {
8665
    $entries  = [];
8766
    $offToSha = [];
...
10180
10281
      if( $meta['file'] !== '' ) {
103
        $offToSha[$meta['file']][$meta['offset']]
104
          = $sha;
82
        $offToSha[$meta['file']][$meta['offset']] = $sha;
10583
      }
10684
    }
10785
10886
    foreach( $entries as &$e ) {
109
      if(
110
        $e['packType'] === 6
111
        && $e['baseOffset'] > 0
112
      ) {
113
        $e['baseSha']
114
          = $offToSha[$e['packFile']][$e['baseOffset']]
115
            ?? '';
116
      }
87
      $e['baseSha'] = $e['packType'] === 6 && $e['baseOffset'] > 0
88
        ? ( $offToSha[$e['packFile']][$e['baseOffset']] ?? '' )
89
        : $e['baseSha'];
11790
    }
11891
11992
    unset( $e );
12093
121
    \uasort(
122
      $entries,
123
      function( array $a, array $b ): int {
124
        $cmp = $a['packFile'] <=> $b['packFile'];
94
    $files   = [];
95
    $offsets = [];
12596
126
        return $cmp !== 0
127
          ? $cmp
128
          : $a['offset'] <=> $b['offset'];
129
      }
97
    foreach( $entries as $e ) {
98
      $files[]   = $e['packFile'];
99
      $offsets[] = $e['offset'];
100
    }
101
102
    \array_multisort(
103
      $files, \SORT_ASC, \SORT_STRING,
104
      $offsets, \SORT_ASC, \SORT_NUMERIC,
105
      $entries
130106
    );
107
108
    foreach( $entries as $sha => &$e ) {
109
      $e['size'] = $e['baseSha'] === ''
110
        ? $this->getObjectSize( $sha )
111
        : 0;
112
    }
113
114
    unset( $e );
131115
132116
    return $entries;
133117
  }
134118
135
  private function getObjectSize(
136
    string $sha
137
  ): int {
119
  private function getObjectSize( string $sha ): int {
138120
    return $this->packs->getSize( $sha )
139121
      ?: $this->loose->getSize( $sha );
140122
  }
141
142
  private function streamCompressed(
143
    string $sha
144
  ): Generator {
145
    $yielded = false;
146123
147
    foreach(
148
      $this->packs->streamRawCompressed(
149
        $sha
150
      ) as $chunk
151
    ) {
152
      $yielded = true;
153
      yield $chunk;
154
    }
124
  private function streamCompressed( string $sha ): Generator {
125
    $generator = $this->packs->streamRawCompressed( $sha );
126
    $generator->rewind();
155127
156
    if( !$yielded ) {
157
      $deflate = \deflate_init(
158
        \ZLIB_ENCODING_DEFLATE
159
      );
128
    if( $generator->valid() ) {
129
      yield from $generator;
130
    } else {
131
      $deflate = \deflate_init( \ZLIB_ENCODING_DEFLATE );
160132
161
      foreach(
162
        $this->getDecompressedChunks(
163
          $sha
164
        ) as $raw
165
      ) {
133
      foreach( $this->getDecompressedChunks( $sha ) as $raw ) {
166134
        $compressed = \deflate_add(
167135
          $deflate, $raw, \ZLIB_NO_FLUSH
168136
        );
169137
170138
        if( $compressed !== '' ) {
171139
          yield $compressed;
172140
        }
173141
      }
174142
175
      $final = \deflate_add(
176
        $deflate, '', \ZLIB_FINISH
177
      );
143
      $final = \deflate_add( $deflate, '', \ZLIB_FINISH );
178144
179145
      if( $final !== '' ) {
180146
        yield $final;
181147
      }
182148
    }
183149
  }
184
185
  private function getDecompressedChunks(
186
    string $sha
187
  ): Generator {
188
    $any = false;
189150
190
    foreach(
191
      $this->loose->streamChunks( $sha ) as $chunk
192
    ) {
193
      $any = true;
194
      yield $chunk;
195
    }
151
  private function getDecompressedChunks( string $sha ): Generator {
152
    $looseGen = $this->loose->streamChunks( $sha );
153
    $looseGen->rewind();
196154
197
    if( !$any ) {
198
      foreach(
199
        $this->packs->streamGenerator(
200
          $sha
201
        ) as $chunk
202
      ) {
203
        $any = true;
204
        yield $chunk;
205
      }
206
    }
155
    if( $looseGen->valid() ) {
156
      yield from $looseGen;
157
    } else {
158
      $packGen = $this->packs->streamGenerator( $sha );
159
      $packGen->rewind();
207160
208
    if( !$any ) {
209
      $data = $this->packs->read( $sha );
161
      if( $packGen->valid() ) {
162
        yield from $packGen;
163
      } else {
164
        $data = $this->packs->read( $sha );
210165
211
      if( $data !== '' ) {
212
        yield $data;
166
        if( $data !== '' ) {
167
          yield $data;
168
        }
213169
      }
214170
    }
215171
  }
216172
217
  private function encodeEntryHeader(
218
    int $type,
219
    int $size
220
  ): string {
173
  private function encodeEntryHeader( int $type, int $size ): string {
221174
    $byte = $type << 4 | $size & 0x0f;
222175
    $sz   = $size >> 4;
223176
    $hdr  = '';
224177
225178
    while( $sz > 0 ) {
226179
      $hdr  .= \chr( $byte | 0x80 );
227180
      $byte  = $sz & 0x7f;
228181
      $sz  >>= 7;
229182
    }
230
231
    $hdr .= \chr( $byte );
232183
233
    return $hdr;
184
    return $hdr . \chr( $byte );
234185
  }
235186
236
  private function encodeOffsetDelta(
237
    int $offset
238
  ): string {
187
  private function encodeOffsetDelta( int $offset ): string {
239188
    $buf = \chr( $offset & 0x7F );
240189
    $n   = $offset >> 7;
241190
242191
    while( $n > 0 ) {
243192
      $n--;
244
      $buf = \chr( 0x80 | ($n & 0x7F) ) . $buf;
193
      $buf = \chr( 0x80 | $n & 0x7F ) . $buf;
245194
      $n >>= 7;
246195
    }
M model/RepositoryList.php
11
<?php
22
class RepositoryList {
3
  private const ORDER_FILE    = __DIR__ . '/../order.txt';
43
  private const GIT_EXT       = '.git';
54
  private const GLOB_PATTERN  = '/*';
...
1514
1615
  private string $reposPath;
16
  private string $orderFile;
1717
18
  public function __construct( string $path ) {
18
  public function __construct( string $path, string $orderFile ) {
1919
    $this->reposPath = $path;
20
    $this->orderFile = $orderFile;
2021
  }
2122
...
8283
8384
  private function sortRepositories( array $repos ): array {
84
    $file = self::ORDER_FILE;
85
    $file = $this->orderFile;
8586
8687
    if( file_exists( $file ) ) {
M model/Router.php
3939
  private string $baseHash   = '';
4040
41
  public function __construct( string $reposPath ) {
41
  public function __construct( string $reposPath, string $orderFile ) {
4242
    $this->git = new Git( $reposPath );
43
    $list      = new RepositoryList( $reposPath );
43
    $list      = new RepositoryList( $reposPath, $orderFile );
4444
4545
    $list->eachRepository( function( $repo ) {
M model/Tag.php
5151
  public function render(
5252
    TagRenderer $renderer,
53
    Tag $prevTag
53
    Tag         $prevTag
5454
  ): void {
55
    $renderer->renderTagItem(
55
    $renderer->render(
5656
      $this->name,
5757
      $this->sha,
M pages/HomePage.php
1818
  public function render(): void {
1919
    $this->renderLayout( function() {
20
      echo '<h2>Repositories</h2>';
21
2220
      if( empty( $this->repositories ) ) {
2321
        echo '<div class="empty-state">No repositories found.</div>';
M pages/TagsPage.php
55
66
class TagsPage extends BasePage {
7
  private $currentRepo;
8
  private $git;
7
  private array $currentRepo;
8
  private Git   $git;
99
1010
  public function __construct(
1111
    array $repositories,
1212
    array $currentRepo,
13
    Git $git
13
    Git   $git
1414
  ) {
1515
    parent::__construct(
...
2222
  }
2323
24
  public function render() {
24
  public function render(): void {
2525
    $this->renderLayout( function() {
2626
      $this->renderBreadcrumbs( $this->currentRepo, ['Tags'] );
2727
      echo '<h2>Tags</h2>';
2828
2929
      $tags = [];
30
3031
      $this->git->eachTag( function( Tag $tag ) use ( &$tags ) {
3132
        $tags[] = $tag;
...
5455
5556
        $count = count( $tags );
57
5658
        for( $i = 0; $i < $count; $i++ ) {
57
          $tag     = $tags[$i];
58
          $prevTag = $tags[$i + 1] ?? null;
59
          $tag->render( $renderer, $prevTag );
59
          $tags[$i]->render(
60
            $renderer,
61
            isset( $tags[$i + 1] ) ? $tags[$i + 1] : new MissingTag()
62
          );
6063
        }
6164
M render/Highlighter.php
1414
    $this->content  = $content;
1515
    $this->language = $this->detectLanguage( $mediaType, $filename );
16
    $this->rules    = LanguageDefinitions::get( $this->language );
16
    $this->rules    = \LanguageDefinitions::get( $this->language );
1717
  }
1818
1919
  public function render(): string {
20
    $result = htmlspecialchars( $this->content );
20
    $result = \htmlspecialchars( $this->content );
2121
2222
    if( !empty( $this->rules ) ) {
2323
      $patterns = [];
2424
2525
      foreach( $this->rules as $name => $pattern ) {
2626
        $delimiter  = $pattern[0];
27
        $pos        = strrpos( $pattern, $delimiter ) - 1;
28
        $inner      = substr( $pattern, 1, $pos );
29
        $inner      = str_replace( '~', '\~', $inner );
27
        $inner      = \str_replace(
28
          '~',
29
          '\~',
30
          \substr( $pattern, 1, \strrpos( $pattern, $delimiter ) - 1 )
31
        );
3032
        $patterns[] = "(?P<{$name}>{$inner})";
3133
      }
3234
33
      if( !in_array( $this->language, ['markdown', 'rmd'] ) ) {
35
      if( !\in_array( $this->language, ['markdown', 'rmd'] ) ) {
3436
        $patterns[] = "(?P<punctuation>[\\{\\}\\(\\)\\[\\]\\;\\,\\:])";
3537
      }
3638
3739
      $patterns[] = "(?P<any>[\s\S])";
38
      $imploded   = implode( '|', $patterns );
39
      $combined   = '~' . $imploded . '~msu';
40
      $combined   = '~' . \implode( '|', $patterns ) . '~msu';
4041
41
      $processed = preg_replace_callback( $combined, function( $matches ) {
42
        $output = htmlspecialchars( $matches[0] );
42
      $processed = \preg_replace_callback(
43
        $combined,
44
        function( $matches ) {
45
          $output = \htmlspecialchars( $matches[0] );
4346
44
        foreach( $matches as $key => $value ) {
45
          if( !is_numeric( $key ) && $value !== '' ) {
46
            if( $key === 'any' ) {
47
              $output = htmlspecialchars( $value );
48
            } elseif( $key === 'string_interp' ) {
49
              $output = $this->renderInterpolatedString( $value );
50
            } elseif( $key === 'math' ) {
51
              $output = $this->renderMath( $value );
52
            } else {
53
              $output = $this->wrap( $value, 'hl-' . $key );
54
            }
47
          foreach( $matches as $key => $value ) {
48
            if( !\is_numeric( $key ) && $value !== '' ) {
49
              $output = match( $key ) {
50
                'any'           => \htmlspecialchars( $value ),
51
                'string_interp' => $this->renderInterpolatedString( $value ),
52
                'math'          => $this->renderMath( $value ),
53
                default         => $this->wrap( $value, 'hl-' . $key )
54
              };
5555
56
            break;
56
              break;
57
            }
5758
          }
58
        }
5959
60
        return $output;
61
      }, $this->content );
60
          return $output;
61
        },
62
        $this->content
63
      );
6264
63
      if( is_string( $processed ) ) {
64
        $result = $processed;
65
      }
65
      $result = \is_string( $processed ) ? $processed : $result;
6666
    }
6767
6868
    return $result;
6969
  }
7070
7171
  private function renderInterpolatedString( string $content ): string {
7272
    $pattern = '/(\$\{[a-zA-Z0-9_]+\}|\$[a-zA-Z0-9_]+)/';
7373
7474
    return $this->processSegments( $content, $pattern, function( $part ) {
75
      if( !str_starts_with( $part, '$' ) || strlen( $part ) <= 1 ) {
76
        $out = $this->wrap( $part, 'hl-string' );
77
      } else {
78
        $isComplex = str_starts_with( $part, '${' ) &&
79
                     str_ends_with( $part, '}' );
75
      $out = $this->wrap( $part, 'hl-string' );
8076
81
        $inner  = $isComplex ? substr( $part, 2, -1 ) : substr( $part, 1 );
77
      if( \str_starts_with( $part, '$' ) && \strlen( $part ) > 1 ) {
78
        $isComplex = \str_starts_with( $part, '${' ) &&
79
                     \str_ends_with( $part, '}' );
80
81
        $inner  = $isComplex
82
          ? \substr( $part, 2, -1 )
83
          : \substr( $part, 1 );
8284
        $prefix = $isComplex ? '${' : '$';
8385
        $suffix = $isComplex
...
98100
99101
    return $this->processSegments( $content, $pattern, function( $part ) {
100
      $output = $this->wrap( $part, 'hl-math' );
101
102
      if( str_starts_with( $part, '`' ) && str_ends_with( $part, '`' ) ) {
103
        $output = $this->wrap( $part, 'hl-function' );
104
      }
102
      $isFunc = \str_starts_with( $part, '`' ) &&
103
                \str_ends_with( $part, '`' );
105104
106
      return $output;
105
      return $this->wrap( $part, $isFunc ? 'hl-function' : 'hl-math' );
107106
    } );
108107
  }
109108
110109
  private function processSegments(
111110
    string $content,
112111
    string $pattern,
113112
    callable $callback
114113
  ): string {
115
    $parts  = preg_split( $pattern, $content, -1, PREG_SPLIT_DELIM_CAPTURE );
116114
    $output = '';
115
116
    $parts = \preg_split(
117
      $pattern,
118
      $content,
119
      -1,
120
      PREG_SPLIT_DELIM_CAPTURE
121
    );
117122
118123
    foreach( $parts as $part ) {
...
130135
    bool $escape = true
131136
  ): string {
132
    $safeContent = $content;
133
134
    if( $escape ) {
135
      $safeContent = htmlspecialchars( $content );
136
    }
137
    $safe = $escape ? \htmlspecialchars( $content ) : $content;
137138
138
    return '<span class="' . $className . '">' . $safeContent . '</span>';
139
    return '<span class="' . $className . '">' . $safe . '</span>';
139140
  }
140141
141142
  private function detectLanguage(
142143
    string $mediaType,
143144
    string $filename
144145
  ): string {
145
    $basename  = basename( $filename );
146
    $extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
147
    $language  = match( $basename ) {
148
      'Containerfile',
149
      'Dockerfile'  => 'containerfile',
150
      'Makefile'    => 'makefile',
151
      'Jenkinsfile' => 'groovy',
152
      default       => ''
153
    };
154
155
    if( $language === '' ) {
156
      $language = match( $extension ) {
157
        'php', 'phtml', 'php8', 'php7' => 'php',
158
        'c', 'h'                       => 'c',
159
        'cpp', 'hpp', 'cc', 'cxx'      => 'cpp',
160
        'cs', 'csx'                    => 'csharp',
161
        'java'                         => 'java',
162
        'kt', 'kts'                    => 'kotlin',
163
        'scala', 'sc'                  => 'scala',
164
        'groovy', 'gvy'                => 'groovy',
165
        'js', 'jsx', 'mjs'             => 'javascript',
166
        'ts', 'tsx'                    => 'typescript',
167
        'dart'                         => 'dart',
168
        'swift'                        => 'swift',
169
        'go'                           => 'go',
170
        'rs'                           => 'rust',
171
        'py', 'pyw'                    => 'python',
172
        'rb', 'erb'                    => 'ruby',
173
        'pl', 'pm', 't'                => 'perl',
174
        'lua'                          => 'lua',
175
        'sh', 'bash', 'zsh'            => 'bash',
176
        'ps1', 'psm1', 'psd1'          => 'powershell',
177
        'bat', 'cmd'                   => 'batch',
178
        'md', 'markdown'               => 'markdown',
179
        'rmd'                          => 'rmd',
180
        'r'                            => 'r',
181
        'xml', 'svg'                   => 'xml',
182
        'xsl', 'xslt'                  => 'xslt',
183
        'html', 'htm'                  => 'html',
184
        'css'                          => 'css',
185
        'json', 'lock'                 => 'json',
186
        'sql'                          => 'sql',
187
        'yaml', 'yml'                  => 'yaml',
188
        'gradle'                       => 'gradle',
189
        'tex', 'sty', 'cls', 'ltx'     => 'tex',
190
        'properties', 'prop'           => 'properties',
191
        'ini', 'cfg', 'conf'           => 'ini',
192
        'toml'                         => 'toml',
193
        'mk', 'mak'                    => 'makefile',
194
        'diff', 'patch'                => 'diff',
195
        'for', 'f', 'f90', 'f95'       => 'fortran',
196
        default                        => ''
197
      };
198
    }
146
    $basename  = \basename( $filename );
147
    $extension = \strtolower(
148
      \pathinfo( $filename, PATHINFO_EXTENSION )
149
    );
199150
200
    if( $language === '' ) {
201
       $language = match( $mediaType ) {
202
        'text/x-php', 'application/x-php',
203
        'application/x-httpd-php'           => 'php',
204
        'text/html'                         => 'html',
205
        'text/css'                          => 'css',
206
        'application/javascript',
207
        'text/javascript',
208
        'text/x-javascript'                 => 'javascript',
209
        'application/json', 'text/json',
210
        'application/x-json'                => 'json',
211
        'application/xml', 'text/xml',
212
        'image/svg+xml'                     => 'xml',
213
        'application/xslt+xml'              => 'xslt',
214
        'text/x-shellscript',
215
        'application/x-sh'                  => 'bash',
216
        'text/x-c', 'text/x-csrc'           => 'c',
217
        'text/x-c++src', 'text/x-c++',
218
        'text/x-cpp'                        => 'cpp',
219
        'text/x-csharp'                     => 'csharp',
220
        'text/x-java',
221
        'text/x-java-source',
222
        'application/java-archive'          => 'java',
223
        'text/x-kotlin'                     => 'kotlin',
224
        'text/x-scala'                      => 'scala',
225
        'text/x-swift'                      => 'swift',
226
        'text/x-python',
227
        'application/x-python-code'         => 'python',
228
        'text/x-ruby', 'application/x-ruby' => 'ruby',
229
        'text/x-perl', 'application/x-perl' => 'perl',
230
        'text/x-go', 'text/go'              => 'go',
231
        'text/rust', 'text/x-rust'          => 'rust',
232
        'text/x-lua', 'text/lua'            => 'lua',
233
        'text/markdown',
234
        'text/x-markdown'                   => 'markdown',
235
        'text/x-r', 'text/x-r-source',
236
        'application/R'                     => 'r',
237
        'application/sql', 'text/sql',
238
        'text/x-sql'                        => 'sql',
239
        'text/yaml', 'text/x-yaml',
240
        'application/yaml'                  => 'yaml',
241
        'application/typescript',
242
        'text/typescript'                   => 'typescript',
243
        'text/x-gradle'                     => 'gradle',
244
        'text/x-tex', 'application/x-tex'   => 'tex',
245
        'text/x-java-properties',
246
        'text/properties'                   => 'properties',
247
        'text/ini', 'application/x-ini'     => 'ini',
248
        'application/toml', 'text/toml'     => 'toml',
249
        'text/x-diff', 'text/x-patch'       => 'diff',
250
        'text/x-fortran'                    => 'fortran',
251
        default                             => 'text'
252
      };
253
    }
151
    $key = match( true ) {
152
      \in_array( $basename, [
153
        'Containerfile', 'Dockerfile', 'Makefile', 'Jenkinsfile'
154
      ], true )         => $basename,
155
      $extension !== '' => $extension,
156
      default           => $mediaType
157
    };
254158
255
    return $language;
159
    return match( $key ) {
160
      'Containerfile', 'Dockerfile' => 'containerfile',
161
      'Makefile', 'mk', 'mak'       => 'makefile',
162
      'Jenkinsfile', 'groovy',
163
      'gvy'                         => 'groovy',
164
      'php', 'phtml', 'php8',
165
      'php7', 'text/x-php',
166
      'application/x-php',
167
      'application/x-httpd-php'     => 'php',
168
      'c', 'h', 'text/x-c',
169
      'text/x-csrc'                 => 'c',
170
      'cpp', 'hpp', 'cc', 'cxx',
171
      'text/x-c++src',
172
      'text/x-c++', 'text/x-cpp'    => 'cpp',
173
      'cs', 'csx', 'text/x-csharp'  => 'csharp',
174
      'java', 'text/x-java',
175
      'text/x-java-source'          => 'java',
176
      'kt', 'kts', 'text/x-kotlin'  => 'kotlin',
177
      'scala', 'sc', 'text/x-scala' => 'scala',
178
      'js', 'jsx', 'mjs',
179
      'application/javascript',
180
      'text/javascript',
181
      'text/x-javascript'           => 'javascript',
182
      'ts', 'tsx',
183
      'application/typescript',
184
      'text/typescript'             => 'typescript',
185
      'dart'                        => 'dart',
186
      'swift', 'text/x-swift'       => 'swift',
187
      'go', 'text/x-go', 'text/go'  => 'go',
188
      'rs', 'text/rust',
189
      'text/x-rust'                 => 'rust',
190
      'py', 'pyw', 'text/x-python',
191
      'application/x-python-code'   => 'python',
192
      'rb', 'erb', 'text/x-ruby',
193
      'application/x-ruby'          => 'ruby',
194
      'pl', 'pm', 't',
195
      'text/x-perl',
196
      'application/x-perl'          => 'perl',
197
      'lua', 'text/x-lua',
198
      'text/lua'                    => 'lua',
199
      'sh', 'bash', 'zsh',
200
      'text/x-shellscript',
201
      'application/x-sh'            => 'bash',
202
      'ps1', 'psm1', 'psd1'         => 'powershell',
203
      'bat', 'cmd'                  => 'batch',
204
      'md', 'markdown',
205
      'text/markdown',
206
      'text/x-markdown'             => 'markdown',
207
      'rmd'                         => 'rmd',
208
      'r', 'text/x-r',
209
      'text/x-r-source',
210
      'application/R'               => 'r',
211
      'xml', 'svg',
212
      'application/xml',
213
      'text/xml', 'image/svg+xml'   => 'xml',
214
      'xsl', 'xslt',
215
      'application/xslt+xml'        => 'xslt',
216
      'html', 'htm', 'text/html'    => 'html',
217
      'css', 'text/css'             => 'css',
218
      'json', 'lock',
219
      'application/json',
220
      'text/json',
221
      'application/x-json'          => 'json',
222
      'sql', 'application/sql',
223
      'text/sql', 'text/x-sql'      => 'sql',
224
      'yaml', 'yml', 'text/yaml',
225
      'text/x-yaml',
226
      'application/yaml'            => 'yaml',
227
      'gradle', 'text/x-gradle'     => 'gradle',
228
      'tex', 'sty', 'cls', 'ltx',
229
      'text/x-tex',
230
      'application/x-tex'           => 'tex',
231
      'properties', 'prop',
232
      'text/x-java-properties',
233
      'text/properties'             => 'properties',
234
      'ini', 'cfg', 'conf',
235
      'text/ini',
236
      'application/x-ini'           => 'ini',
237
      'toml', 'application/toml',
238
      'text/toml'                   => 'toml',
239
      'diff', 'patch',
240
      'text/x-diff', 'text/x-patch' => 'diff',
241
      'for', 'f', 'f90', 'f95',
242
      'text/x-fortran'              => 'fortran',
243
      default                       => 'text'
244
    };
256245
  }
257246
}
M render/HtmlCommitRenderer.php
11
<?php
22
require_once __DIR__ . '/CommitRenderer.php';
3
require_once __DIR__ . '/Renderer.php';
34
require_once __DIR__ . '/../model/UrlBuilder.php';
45
5
class HtmlCommitRenderer implements CommitRenderer {
6
class HtmlCommitRenderer extends Renderer implements CommitRenderer {
67
  private string $repoSafeName;
78
...
3031
         ' &bull; ' . date( 'Y-m-d', $date ) . '</span>';
3132
    echo '</div>';
32
  }
33
34
  public function renderTime( int $timestamp ): void {
35
    $tokens = [
36
      31536000 => 'year', 2592000 => 'month', 604800 => 'week',
37
      86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second'
38
    ];
39
    $diff = $timestamp ? time() - $timestamp : null;
40
    $result = 'never';
41
42
    if( $diff && $diff >= 5 ) {
43
      foreach( $tokens as $unit => $text ) {
44
        if( $diff < $unit ) continue;
45
        $num = floor( $diff / $unit );
46
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
47
        break;
48
      }
49
    } elseif( $diff ) {
50
      $result = 'just now';
51
    }
52
53
    echo $result;
5433
  }
5534
}
M render/HtmlFileRenderer.php
22
require_once __DIR__ . '/FileRenderer.php';
33
require_once __DIR__ . '/Highlighter.php';
4
require_once __DIR__ . '/Renderer.php';
45
require_once __DIR__ . '/../model/UrlBuilder.php';
56
6
class HtmlFileRenderer implements FileRenderer {
7
class HtmlFileRenderer extends Renderer implements FileRenderer {
78
  private string $repoSafeName;
89
  private string $currentPath;
910
  private string $currentRef;
1011
11
  public function __construct( string $repoSafeName, string $currentPath = '', string $currentRef = 'HEAD' ) {
12
  public function __construct(
13
    string $repoSafeName,
14
    string $currentPath = '',
15
    string $currentRef = 'HEAD'
16
  ) {
1217
    $this->repoSafeName = $repoSafeName;
13
    $this->currentPath = trim( $currentPath, '/' );
14
    $this->currentRef = $currentRef;
18
    $this->currentPath  = trim( $currentPath, '/' );
19
    $this->currentRef   = $currentRef;
1520
  }
1621
1722
  public function renderListEntry(
1823
    string $name,
1924
    string $sha,
2025
    string $mode,
2126
    string $iconClass,
22
    int $timestamp,
23
    int $size
27
    int    $timestamp,
28
    int    $size
2429
  ): void {
25
    $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') . $name;
30
    $fullPath = ($this->currentPath === '' ? '' : $this->currentPath . '/') .
31
                $name;
2632
27
    $isDir = ($mode === '40000' || $mode === '040000');
33
    $isDir  = $mode === '40000' || $mode === '040000';
2834
    $action = $isDir ? 'tree' : 'blob';
2935
...
3642
3743
    echo '<tr>';
38
    echo '<td class="file-icon-cell"><i class="fas ' . $iconClass . '"></i></td>';
39
    echo '<td class="file-name-cell"><a href="' . $url . '">' . htmlspecialchars( $name ) . '</a></td>';
44
    echo '<td class="file-icon-cell"><i class="fas ' . $iconClass .
45
         '"></i></td>';
46
    echo '<td class="file-name-cell"><a href="' . $url . '">' .
47
         htmlspecialchars( $name ) . '</a></td>';
4048
    echo '<td class="file-mode-cell">' . $this->formatMode( $mode ) . '</td>';
41
    echo '<td class="file-size-cell">' . ($size > 0 ? $this->formatSize( $size ) : '') . '</td>';
49
    echo '<td class="file-size-cell">' .
50
         ($size > 0 ? $this->formatSize( $size ) : '') . '</td>';
4251
    echo '</tr>';
4352
  }
4453
45
  public function renderMedia( File $file, string $url, string $mediaType ): bool {
54
  public function renderMedia(
55
    File   $file,
56
    string $url,
57
    string $mediaType
58
  ): bool {
59
    $result = false;
60
4661
    if( $file->isImage() ) {
47
      echo '<div class="blob-content blob-content-image"><img src="' . $url . '"></div>';
48
      return true;
62
      echo '<div class="blob-content blob-content-image">' .
63
           '<img src="' . $url . '"></div>';
64
      $result = true;
4965
    } elseif( $file->isVideo() ) {
50
      echo '<div class="blob-content blob-content-video"><video controls><source src="' . $url . '" type="' . $mediaType . '"></video></div>';
51
      return true;
66
      echo '<div class="blob-content blob-content-video">' .
67
           '<video controls><source src="' . $url . '" type="' .
68
           $mediaType . '"></video></div>';
69
      $result = true;
5270
    } elseif( $file->isAudio() ) {
53
      echo '<div class="blob-content blob-content-audio"><audio controls><source src="' . $url . '" type="' . $mediaType . '"></audio></div>';
54
      return true;
71
      echo '<div class="blob-content blob-content-audio">' .
72
           '<audio controls><source src="' . $url . '" type="' .
73
           $mediaType . '"></audio></div>';
74
      $result = true;
5575
    }
56
    return false;
76
77
    return $result;
5778
  }
5879
5980
  public function renderSize( int $bytes ): void {
6081
    echo $this->formatSize( $bytes );
61
  }
62
63
  public function highlight( string $filename, string $content, string $mediaType ): string {
64
    return (new Highlighter($filename, $content, $mediaType))->render();
6582
  }
66
67
  public function renderTime( int $timestamp ): void {
68
    $tokens = [
69
      31536000 => 'year', 2592000 => 'month', 604800 => 'week',
70
      86400 => 'day', 3600 => 'hour', 60 => 'minute', 1 => 'second'
71
    ];
72
    $diff = $timestamp ? time() - $timestamp : null;
73
    $result = 'never';
74
75
    if( $diff && $diff >= 5 ) {
76
      foreach( $tokens as $unit => $text ) {
77
        if( $diff < $unit ) continue;
78
        $num = floor( $diff / $unit );
79
        $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
80
        break;
81
      }
82
    } elseif( $diff ) {
83
      $result = 'just now';
84
    }
8583
86
    echo $result;
84
  public function highlight(
85
    string $filename,
86
    string $content,
87
    string $mediaType
88
  ): string {
89
    return (new Highlighter( $filename, $content, $mediaType ))->render();
8790
  }
8891
8992
  private function formatSize( int $bytes ): string {
90
    $units = [ 'B', 'KB', 'MB', 'GB', 'TB' ];
91
    $i = 0;
93
    $units = ['B', 'KB', 'MB', 'GB', 'TB'];
94
    $i     = 0;
9295
9396
    while( $bytes >= 1024 && $i < count( $units ) - 1 ) {
94
      $bytes /= 1024; $i++;
97
      $bytes /= 1024;
98
      $i++;
9599
    }
96100
97101
    return ($bytes === 0 ? 0 : round( $bytes )) . ' ' . $units[$i];
98102
  }
99103
100104
  private function formatMode( string $mode ): string {
101
    switch( $mode ) {
102
      case '100644': return 'w';
103
      case '100755': return 'x';
104
      case '040000': return 'd';
105
      case '120000': return 'l';
106
      case '160000': return 'm';
107
      default: return '?';
108
    }
105
    return match( $mode ) {
106
      '100644' => 'w',
107
      '100755' => 'x',
108
       '40000' => 'd',
109
      '040000' => 'd',
110
      '120000' => 'l',
111
      '160000' => 'm',
112
      default  => '?'
113
    };
109114
  }
110115
}
M render/HtmlTagRenderer.php
11
<?php
22
require_once __DIR__ . '/TagRenderer.php';
3
require_once __DIR__ . '/Renderer.php';
34
require_once __DIR__ . '/../model/UrlBuilder.php';
45
5
class HtmlTagRenderer implements TagRenderer {
6
class HtmlTagRenderer extends Renderer implements TagRenderer {
67
  private string $repoSafeName;
78
89
  public function __construct( string $repoSafeName ) {
910
    $this->repoSafeName = $repoSafeName;
1011
  }
1112
12
  public function renderTagItem(
13
  public function render(
1314
    string $name,
1415
    string $sha,
1516
    string $targetSha,
16
    ?string $prevTargetSha,
17
    int $timestamp,
17
    string $prevTargetSha,
18
    int    $timestamp,
1819
    string $message,
1920
    string $author
...
3132
      ->build();
3233
33
    if( $prevTargetSha ) {
34
      $diffUrl = (new UrlBuilder())
34
    $diffUrl = $prevTargetSha !== ''
35
      ? (new UrlBuilder())
3536
        ->withRepo( $this->repoSafeName )
3637
        ->withAction( 'compare' )
3738
        ->withHash( $targetSha )
3839
        ->withName( $prevTargetSha )
39
        ->build();
40
    } else {
41
      $diffUrl = $commitUrl;
42
    }
40
        ->build()
41
      : $commitUrl;
4342
4443
    echo '<tr>';
4544
    echo '<td class="tag-name">';
4645
    echo '<a href="' . $filesUrl . '"><i class="fas fa-tag"></i> ' .
4746
         htmlspecialchars( $name ) . '</a>';
4847
    echo '</td>';
4948
    echo '<td class="tag-message">';
5049
51
    echo ($message !== '') ? htmlspecialchars( strtok( $message, "\n" ) ) :
52
      '<span style="color: #484f58; font-style: italic;">No description</span>';
50
    echo $message !== ''
51
      ? htmlspecialchars( strtok( $message, "\n" ) )
52
      : '<span style="color: #484f58; font-style: italic;">' .
53
        'No description</span>';
5354
5455
    echo '</td>';
55
    echo '<td class="tag-author">' . htmlspecialchars( $author ) . '</td>';
56
    echo '<td class="tag-author">' .
57
         htmlspecialchars( $author ) . '</td>';
5658
    echo '<td class="tag-time">';
59
5760
    $this->renderTime( $timestamp );
61
5862
    echo '</td>';
5963
    echo '<td class="tag-hash">';
6064
    echo '<a href="' . $diffUrl . '" class="commit-hash">' .
6165
         substr( $sha, 0, 7 ) . '</a>';
6266
    echo '</td>';
6367
    echo '</tr>';
64
  }
65
66
  public function renderTime( int $timestamp ): void {
67
    if( !$timestamp ) {
68
      echo 'never';
69
      return;
70
    }
71
72
    $diff = time() - $timestamp;
73
74
    if( $diff < 5 ) {
75
      echo 'just now';
76
      return;
77
    }
78
79
    $tokens = [
80
      31536000 => 'year',
81
      2592000 => 'month',
82
      604800 => 'week',
83
      86400 => 'day',
84
      3600 => 'hour',
85
      60 => 'minute',
86
      1 => 'second'
87
    ];
88
89
    foreach( $tokens as $unit => $text ) {
90
      if( $diff < $unit ) {
91
        continue;
92
      }
93
94
      $num = floor( $diff / $unit );
95
96
      echo $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
97
      return;
98
    }
9968
  }
10069
}
A render/Renderer.php
1
<?php
2
abstract class Renderer {
3
  public function renderTime( int $timestamp ): void {
4
    $result = 'never';
5
6
    if( $timestamp !== 0 ) {
7
      $diff = time() - $timestamp;
8
9
      if( $diff < 5 ) {
10
        $result = 'just now';
11
      } else {
12
        $tokens = [
13
          31536000 => 'year',
14
          2592000  => 'month',
15
          604800   => 'week',
16
          86400    => 'day',
17
          3600     => 'hour',
18
          60       => 'minute',
19
          1        => 'second'
20
        ];
21
22
        foreach( $tokens as $unit => $text ) {
23
          if( $diff >= $unit ) {
24
            $num    = floor( $diff / $unit );
25
            $result = $num . ' ' . $text . ($num > 1 ? 's' : '') . ' ago';
26
27
            break;
28
          }
29
        }
30
      }
31
    }
32
33
    echo $result;
34
  }
35
}
136
M render/TagRenderer.php
11
<?php
22
interface TagRenderer {
3
  public function renderTagItem(
3
  public function render(
44
    string $name,
55
    string $sha,
66
    string $targetSha,
77
    string $prevTargetSha,
8
    int $timestamp,
8
    int    $timestamp,
99
    string $message,
1010
    string $author