言語

守矢諏訪子 <suwako@076.moe>、2026-11-13 14:31
コミット 35614aba94fd11d65308cc638aac7f8d106b1a53 ・親 eb7ddda ・ファイルを見る

8個のファイル、+584 -2

変更 README-JP.md
@@ -301,6 +301,7 @@
301301 ```
302302 鍵毎に `gitler.php serve <ユーザー>` を実行するので、ユーザーが出来るのは `git clone`・`git push`(`git@example.com:user/repo.git`)だけです。`AuthorizedKeysCommand` を使わない場合は、`php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys`(例えば cron から)で同じ行を書けます。
303303 * リポジトリを整理する為に Cron のジョブ `gitlergc` を追加するか(例:`php cron.php add "@daily" gitlergc`)、`php gitler.php gc` を実行して下さい。
304+* リポジトリのページには GitHub の様に言語の割合が出ます(バイト数で、ファイル名・拡張子・`#!` で決めます)。数えるのはプログラミングとマークアップの言語だけで、データ(JSON、YAML 等)、文章(Markdown 等)、外部のコード(`vendor/`、`node_modules/` 等)、ドキュメント(`docs/`、`examples/` 等)、生成されたファイルは数えません。ルートの `.gitattributes` の `linguist-language`、`linguist-vendored`、`linguist-generated`、`linguist-documentation`、`linguist-detectable` で変えられます(例:`*.h linguist-language=C++`)。結果はリポジトリの中の `gitler-languages.json` にツリー毎に保存し、一覧には既定のブランチの一番多い言語が出ます。
304305
305306 ### TLS 1.3・1.2(php_openssl は不要)
306307 HTTPS のリクエスト(`Curl`、ActivityPub)、SMTP の STARTTLS(`Mailer`)、MySQL・PostgreSQL の TLS は PHP だけで書かれた TLS 1.3・1.2 のクライアント `Std\Lib\Tls`(`php_gmp` が必要)を使うので、`php_openssl` は不要です。RSA の鍵と署名(ActivityPub)は `Std\Lib\Crypto\Rsa` を使い、今までの PEM の鍵もそのまま使えます。
変更 README.md
@@ -299,6 +299,7 @@
299299 ```
300300 Each key runs `gitler.php serve <user>`, so the user only gets `git clone`/`git push` (`git@example.com:user/repo.git`). Without `AuthorizedKeysCommand`, `php gitler.php authorized-keys > /var/www/git/.ssh/authorized_keys` (for example from cron) writes the same lines.
301301 * Add the Cron job `gitlergc` (for example `php cron.php add "@daily" gitlergc`) to pack the repositories, or run `php gitler.php gc`.
302+* The repository page shows the share of each language like GitHub (by bytes; file names, extensions and `#!`). Only programming and markup languages count: data (JSON, YAML…), prose (Markdown…), vendored code (`vendor/`, `node_modules/`…), documentation (`docs/`, `examples/`…) and generated files are left out. Change it with `linguist-language`, `linguist-vendored`, `linguist-generated`, `linguist-documentation` and `linguist-detectable` in the root `.gitattributes` (for example `*.h linguist-language=C++`). Results are stored per tree in `gitler-languages.json` inside the repository, and lists show the main language of the default branch.
302303
303304 ### TLS 1.3 and 1.2 (no php_openssl)
304305 HTTPS requests (`Curl`, ActivityPub), SMTP STARTTLS (`Mailer`) and MySQL and PostgreSQL over TLS use `Std\Lib\Tls`, a TLS 1.3 and 1.2 client written in pure PHP (it needs `php_gmp`), so `php_openssl` is not needed. RSA keys and signatures (ActivityPub) use `Std\Lib\Crypto\Rsa`, and existing PEM keys keep working.
@@ -348,6 +348,42 @@
348348 font-size: 1.1em;
349349 }
350350
351+.gl-langs {
352+ margin: 8px 0;
353+}
354+
355+.gl-langbar {
356+ display: flex;
357+ height: 8px;
358+ overflow: hidden;
359+ border: 1px solid #746c75;
360+ border-radius: 4px;
361+}
362+
363+.gl-langbar span {
364+ display: block;
365+ min-width: 1px;
366+}
367+
368+.gl-langlist {
369+ display: flex;
370+ flex-wrap: wrap;
371+ gap: 2px 16px;
372+ list-style: none;
373+ padding: 0;
374+ margin: 4px 0;
375+ font-size: 0.9em;
376+}
377+
378+.gl-langdot {
379+ display: inline-block;
380+ width: 10px;
381+ height: 10px;
382+ border-radius: 50%;
383+ margin-right: 4px;
384+ vertical-align: middle;
385+}
386+
351387 button.danger {
352388 border-color: #ee4030;
353389 color: #ee4030;
@@ -49,6 +49,7 @@
4949 use Std\Lib\Gitler\Archive;
5050 use Std\Lib\Gitler\Gitler as Hosting;
5151 use Std\Lib\Gitler\Keys;
52+use Std\Lib\Gitler\Languages;
5253 use Std\Lib\Markdown;
5354 use Std\Lib\Syntax;
5455 use Std\Lib\Template;
@@ -225,8 +226,18 @@
225226 $text = $this->repo->blob($readme[1]);
226227 $readmeHtml = preg_match('/\.(md|markdown)$/i', $readme[0]) ? $this->markdown($text, $ref, $path) : '<pre>'.htmlspecialchars($text, ENT_QUOTES, 'UTF-8').'</pre>';
227228 }
229+ // ルートには言語の割合
230+ $langs = [];
231+ if ($path === '') {
232+ try {
233+ $langs = Languages::present(Languages::get($this->repo, $commit, $ref === $this->defaultRef()) ?? []);
234+ } catch (GitException) {
235+ $langs = [];
236+ }
237+ }
228238 $c = $this->repo->commit($commit);
229239 $this->renderRepo('gitler/tree', $path === '' ? '' : $path, [
240+ 'langs' => $langs,
230241 'entries' => array_merge($dirs, $files),
231242 'readmeName' => $readme[0] ?? '',
232243 'readme' => $readmeHtml,
@@ -604,7 +615,8 @@
604615
605616 private function presentRepo(array $r): array {
606617 return ['owner' => $r['owner'], 'name' => $r['name'], 'description' => $r['meta']['description'], 'private' => $r['meta']['private'],
607- 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-'];
618+ 'url' => '/gitler/'.$r['owner'].'/'.rawurlencode($r['name']), 'updated' => $r['updated'] ? date('Y-m-d H:i', $r['updated']) : '-',
619+ 'lang' => Languages::primary(Hosting::path($r['owner'], $r['name']))];
608620 }
609621
610622 private function presentCommit(\Std\Lib\Git\Commit $c): array {
@@ -0,0 +1,452 @@
1+<?php
2+/*************************************************************
3+# 076 License
4+
5+Copyright (c) テクニカル諏訪子
6+
7+Permission is hereby granted to any person obtaining a copy of the software
8+Little Beast (the "Software") to use, modify, merge, copy, publish, distribute,
9+sublicense, and/or sell copies of the Software, subject to the following conditions:
10+
11+ 1. **Origin Attribution**:
12+ - You must not misrepresent the origin of the Software; you must not claim
13+ you created the original Software.
14+ - If the Software is used in a product, you must either:
15+ a. Provide clear attribution in the product's documentation, user interface,
16+ or other visible areas, **OR**
17+ b. Pay the original developers a fee they specify in writing.
18+ 2. **Usage Restriction**:
19+ - The Software, or any derivative works, dependencies, or libraries
20+ incorporating it, must not be used for censorship or to suppress freedom of
21+ speech, expression, or creativity. Prohibited uses include, but are not
22+ limited to:
23+ - Censorship of so-called "hate speech", visuals, non-mainstream opinions,
24+ ideas, or objective reality.
25+ - Tools or systems designed to restrict access to information or
26+ artistic works.
27+ 3. **Notice Preservation**:
28+ - This license and the above copyright notice must remain intact in all copies
29+ of the source code.
30+
31+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
32+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
33+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
34+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
35+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
36+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37+ */
38+namespace Std\Lib\Gitler;
39+
40+use Std\Lib\Git\Diff;
41+use Std\Lib\Git\GitException;
42+use Std\Lib\Git\Repository;
43+use Std\Lib\Git\Tree;
44+
45+/**
46+ * リポジトリの言語の割合(GitHubのLinguistの様に)
47+ *
48+ * ファイルの名前・拡張子・#!から言語を決め、言語毎のバイト数を数える。数えるのはプログラミングとマークアップの言語だけで、
49+ * データ(JSON等)と文章(Markdown等)、外部のコード(vendor/等)、ドキュメント(docs/等)、生成されたファイル、リンクとサブモジュールは数えない。
50+ * ルートの.gitattributesのlinguist-language、linguist-vendored、linguist-generated、linguist-documentation、linguist-detectableで変えられる。
51+ * 結果はリポジトリの中のgitler-languages.jsonにツリー毎に保存する。
52+ */
53+final class Languages {
54+ /** 保存する形式(言語の表を変えたら上げる) */
55+ private const VERSION = 1;
56+ /** 保存するツリーの数 */
57+ private const KEEP = 20;
58+ /** これよりファイルが多いリポジトリは数えない */
59+ private const MAX_FILES = 100000;
60+ /** 中身で言語を決めるファイルの最大の大きさ */
61+ private const MAX_PEEK = 524288;
62+ /** 表示する言語の数(それより多ければ残りは「その他」) */
63+ private const SHOW = 8;
64+
65+ private const PROGRAMMING = 'p';
66+ private const MARKUP = 'm';
67+ private const DATA = 'd';
68+ private const PROSE = 't';
69+
70+ /** 名前 => [種類, 色](色はLinguistと同じ、MaronとSlangは独自) */
71+ private const LANGS = [
72+ 'Ada' => ['p', '#02f88c'], 'Assembly' => ['p', '#6E4C13'], 'AWK' => ['p', '#c30e9b'], 'Batchfile' => ['p', '#C1F12E'],
73+ 'C' => ['p', '#555555'], 'C#' => ['p', '#178600'], 'C++' => ['p', '#f34b7d'], 'Clojure' => ['p', '#db5855'],
74+ 'CMake' => ['p', '#DA3434'], 'Common Lisp' => ['p', '#3fb68b'], 'Coq' => ['p', '#d0b68c'], 'Crystal' => ['p', '#000100'],
75+ 'CSS' => ['m', '#663399'], 'CUDA' => ['p', '#3A4E3A'], 'Cython' => ['p', '#fedf5b'], 'D' => ['p', '#ba595e'],
76+ 'Dart' => ['p', '#00B4AB'], 'Dockerfile' => ['p', '#384d54'], 'Elixir' => ['p', '#6e4a7e'], 'Elm' => ['p', '#60B5CC'],
77+ 'Emacs Lisp' => ['p', '#c065db'], 'Erlang' => ['p', '#B83998'], 'F#' => ['p', '#b845fc'], 'Forth' => ['p', '#341708'],
78+ 'Fortran' => ['p', '#4d41b1'], 'GDScript' => ['p', '#355570'], 'Gleam' => ['p', '#ffaff3'], 'GLSL' => ['p', '#5686a5'],
79+ 'Go' => ['p', '#00ADD8'], 'Groovy' => ['p', '#4298b8'], 'Handlebars' => ['m', '#f7931e'], 'Hare' => ['p', '#9d7424'],
80+ 'Haskell' => ['p', '#5e5086'], 'Haxe' => ['p', '#df7900'], 'HCL' => ['p', '#844FBA'], 'HLSL' => ['p', '#aace60'],
81+ 'HTML' => ['m', '#e34c26'], 'Java' => ['p', '#b07219'], 'JavaScript' => ['p', '#f1e05a'], 'Julia' => ['p', '#a270ba'],
82+ 'Jupyter Notebook' => ['m', '#DA5B0B'], 'Just' => ['p', '#384d54'], 'Kotlin' => ['p', '#A97BFF'], 'Less' => ['m', '#1d365d'],
83+ 'Lex' => ['p', '#DBCA00'], 'Lua' => ['p', '#000080'], 'Makefile' => ['p', '#427819'], 'Maron' => ['m', '#ea79d8'],
84+ 'MATLAB' => ['p', '#e16737'], 'Meson' => ['p', '#007800'], 'Metal' => ['p', '#8f14e9'], 'Nim' => ['p', '#ffc200'],
85+ 'Nix' => ['p', '#7e7eff'], 'Objective-C' => ['p', '#438eff'], 'Objective-C++' => ['p', '#6866fb'], 'OCaml' => ['p', '#ef7a08'],
86+ 'Odin' => ['p', '#60AFFE'], 'Pascal' => ['p', '#E3F171'], 'Perl' => ['p', '#0298c3'], 'PHP' => ['p', '#4F5D95'],
87+ 'PowerShell' => ['p', '#012456'], 'Python' => ['p', '#3572A5'], 'QML' => ['p', '#44a51c'], 'R' => ['p', '#198CE7'],
88+ 'Racket' => ['p', '#3c5caa'], 'Raku' => ['p', '#0000fb'], 'Roff' => ['m', '#ecdebe'], 'Ruby' => ['p', '#701516'],
89+ 'Rust' => ['p', '#dea584'], 'Sass' => ['m', '#a53b70'], 'Scala' => ['p', '#c22d40'], 'Scheme' => ['p', '#1e4aec'],
90+ 'SCSS' => ['m', '#c6538c'], 'Shell' => ['p', '#89e051'], 'Slang' => ['p', '#1fbec9'], 'Solidity' => ['p', '#AA6746'],
91+ 'Starlark' => ['p', '#76d275'], 'Svelte' => ['m', '#ff3e00'], 'Swift' => ['p', '#F05138'], 'SystemVerilog' => ['p', '#DAE1C2'],
92+ 'Tcl' => ['p', '#e4cc98'], 'TeX' => ['m', '#3D6117'], 'TSX' => ['p', '#3178c6'], 'Twig' => ['m', '#c1d026'],
93+ 'TypeScript' => ['p', '#3178c6'], 'V' => ['p', '#4f87c4'], 'Verilog' => ['p', '#b2b7f8'], 'VHDL' => ['p', '#adb2cb'],
94+ 'Vim Script' => ['p', '#199f4b'], 'Visual Basic .NET' => ['p', '#945db7'], 'Vue' => ['m', '#41b883'], 'WGSL' => ['p', '#1a5e9a'],
95+ 'Yacc' => ['p', '#4B6C4B'], 'Zig' => ['p', '#ec915c'],
96+ // 数えない(linguist-detectableで数えられる)
97+ 'JSON' => ['d', '#292929'], 'Markdown' => ['t', '#083fa1'], 'SQL' => ['d', '#e38c00'], 'TOML' => ['d', '#9c4221'],
98+ 'XML' => ['d', '#0060ac'], 'YAML' => ['d', '#cb171e'],
99+ ];
100+
101+ /** 拡張子(小文字) => 言語 */
102+ private const EXTENSIONS = [
103+ 'adb' => 'Ada', 'ads' => 'Ada', 'asm' => 'Assembly', 'nasm' => 'Assembly', 's' => 'Assembly', 'awk' => 'AWK',
104+ 'bat' => 'Batchfile', 'cmd' => 'Batchfile', 'c' => 'C', 'cs' => 'C#', 'csx' => 'C#',
105+ 'cpp' => 'C++', 'cc' => 'C++', 'cxx' => 'C++', 'c++' => 'C++', 'hpp' => 'C++', 'hh' => 'C++', 'hxx' => 'C++', 'h++' => 'C++',
106+ 'ipp' => 'C++', 'tpp' => 'C++', 'inl' => 'C++', 'ixx' => 'C++', 'cppm' => 'C++',
107+ 'clj' => 'Clojure', 'cljs' => 'Clojure', 'cljc' => 'Clojure', 'cmake' => 'CMake', 'lisp' => 'Common Lisp', 'lsp' => 'Common Lisp',
108+ 'cr' => 'Crystal', 'css' => 'CSS', 'cu' => 'CUDA', 'cuh' => 'CUDA', 'pyx' => 'Cython', 'pxd' => 'Cython', 'dart' => 'Dart',
109+ 'dockerfile' => 'Dockerfile', 'ex' => 'Elixir', 'exs' => 'Elixir', 'elm' => 'Elm', 'el' => 'Emacs Lisp', 'erl' => 'Erlang', 'hrl' => 'Erlang',
110+ 'fsi' => 'F#', 'fsx' => 'F#', 'fth' => 'Forth', '4th' => 'Forth', 'forth' => 'Forth',
111+ 'f' => 'Fortran', 'for' => 'Fortran', 'f77' => 'Fortran', 'f90' => 'Fortran', 'f95' => 'Fortran', 'f03' => 'Fortran', 'f08' => 'Fortran',
112+ 'gd' => 'GDScript', 'gleam' => 'Gleam', 'glsl' => 'GLSL', 'vert' => 'GLSL', 'frag' => 'GLSL', 'geom' => 'GLSL', 'comp' => 'GLSL',
113+ 'tesc' => 'GLSL', 'tese' => 'GLSL', 'rgen' => 'GLSL', 'rchit' => 'GLSL', 'rmiss' => 'GLSL', 'rahit' => 'GLSL', 'mesh' => 'GLSL', 'task' => 'GLSL',
114+ 'go' => 'Go', 'groovy' => 'Groovy', 'gradle' => 'Groovy', 'hbs' => 'Handlebars', 'handlebars' => 'Handlebars', 'ha' => 'Hare',
115+ 'hs' => 'Haskell', 'lhs' => 'Haskell', 'hx' => 'Haxe', 'tf' => 'HCL', 'hcl' => 'HCL', 'hlsl' => 'HLSL', 'hlsli' => 'HLSL', 'fx' => 'HLSL', 'fxh' => 'HLSL',
116+ 'html' => 'HTML', 'htm' => 'HTML', 'xhtml' => 'HTML', 'java' => 'Java', 'js' => 'JavaScript', 'mjs' => 'JavaScript', 'cjs' => 'JavaScript',
117+ 'jsx' => 'JavaScript', 'jl' => 'Julia', 'ipynb' => 'Jupyter Notebook', 'just' => 'Just', 'kt' => 'Kotlin', 'kts' => 'Kotlin', 'less' => 'Less',
118+ 'l' => 'Lex', 'lex' => 'Lex', 'lua' => 'Lua', 'mk' => 'Makefile', 'mak' => 'Makefile', 'maron' => 'Maron', 'metal' => 'Metal',
119+ 'nim' => 'Nim', 'nims' => 'Nim', 'nimble' => 'Nim', 'nix' => 'Nix', 'mm' => 'Objective-C++', 'ml' => 'OCaml', 'mli' => 'OCaml',
120+ 'odin' => 'Odin', 'pas' => 'Pascal', 'pp' => 'Pascal', 'dpr' => 'Pascal', 'lpr' => 'Pascal', 'pl' => 'Perl', 'pm' => 'Perl',
121+ 'php' => 'PHP', 'phtml' => 'PHP', 'php3' => 'PHP', 'php4' => 'PHP', 'php5' => 'PHP', 'phps' => 'PHP',
122+ 'ps1' => 'PowerShell', 'psm1' => 'PowerShell', 'psd1' => 'PowerShell', 'py' => 'Python', 'pyw' => 'Python', 'pyi' => 'Python',
123+ 'qml' => 'QML', 'r' => 'R', 'rkt' => 'Racket', 'raku' => 'Raku', 'rakumod' => 'Raku', 'p6' => 'Raku',
124+ 'roff' => 'Roff', 'man' => 'Roff', 'mdoc' => 'Roff', '1' => 'Roff', '2' => 'Roff', '3' => 'Roff', '4' => 'Roff', '5' => 'Roff',
125+ '6' => 'Roff', '7' => 'Roff', '8' => 'Roff', '9' => 'Roff',
126+ 'rb' => 'Ruby', 'rake' => 'Ruby', 'gemspec' => 'Ruby', 'rs' => 'Rust', 'sass' => 'Sass', 'scala' => 'Scala', 'scm' => 'Scheme', 'ss' => 'Scheme',
127+ 'scss' => 'SCSS', 'sh' => 'Shell', 'bash' => 'Shell', 'zsh' => 'Shell', 'ksh' => 'Shell', 'slang' => 'Slang', 'sol' => 'Solidity',
128+ 'bzl' => 'Starlark', 'star' => 'Starlark', 'svelte' => 'Svelte', 'swift' => 'Swift', 'sv' => 'SystemVerilog', 'svh' => 'SystemVerilog',
129+ 'tcl' => 'Tcl', 'tex' => 'TeX', 'sty' => 'TeX', 'ltx' => 'TeX', 'tsx' => 'TSX', 'twig' => 'Twig', 'ts' => 'TypeScript', 'mts' => 'TypeScript',
130+ 'cts' => 'TypeScript', 'vhd' => 'VHDL', 'vhdl' => 'VHDL', 'vim' => 'Vim Script', 'vb' => 'Visual Basic .NET', 'vue' => 'Vue',
131+ 'wgsl' => 'WGSL', 'y' => 'Yacc', 'yy' => 'Yacc', 'zig' => 'Zig', 'zon' => 'Zig',
132+ 'json' => 'JSON', 'md' => 'Markdown', 'markdown' => 'Markdown', 'sql' => 'SQL', 'toml' => 'TOML', 'xml' => 'XML', 'yml' => 'YAML', 'yaml' => 'YAML',
133+ ];
134+
135+ /** 拡張子で決まらない物(中身で決める、決まらなければこの言語) */
136+ private const AMBIGUOUS = ['h' => 'C', 'm' => 'Objective-C', 'v' => 'V', 'fs' => 'F#', 'd' => 'D'];
137+
138+ /** ファイル名 => 言語 */
139+ private const FILENAMES = [
140+ 'Makefile' => 'Makefile', 'makefile' => 'Makefile', 'GNUmakefile' => 'Makefile', 'BSDmakefile' => 'Makefile', 'Kbuild' => 'Makefile',
141+ 'Makefile.am' => 'Makefile', 'CMakeLists.txt' => 'CMake', 'meson.build' => 'Meson', 'meson_options.txt' => 'Meson', 'meson.options' => 'Meson',
142+ 'Dockerfile' => 'Dockerfile', 'Containerfile' => 'Dockerfile', 'Rakefile' => 'Ruby', 'Gemfile' => 'Ruby', 'Vagrantfile' => 'Ruby',
143+ 'Podfile' => 'Ruby', 'Justfile' => 'Just', 'justfile' => 'Just', '.justfile' => 'Just', 'BUILD' => 'Starlark', 'BUILD.bazel' => 'Starlark',
144+ 'WORKSPACE' => 'Starlark', 'WORKSPACE.bazel' => 'Starlark', 'MODULE.bazel' => 'Starlark', '.bashrc' => 'Shell', '.bash_profile' => 'Shell',
145+ '.bash_logout' => 'Shell', '.profile' => 'Shell', '.zshrc' => 'Shell', '.zprofile' => 'Shell', '.kshrc' => 'Shell', '.xinitrc' => 'Shell',
146+ '.xsession' => 'Shell', 'PKGBUILD' => 'Shell', 'APKBUILD' => 'Shell', '.vimrc' => 'Vim Script', '_vimrc' => 'Vim Script',
147+ '.gvimrc' => 'Vim Script', '.emacs' => 'Emacs Lisp', 'SConstruct' => 'Python', 'SConscript' => 'Python', 'Jenkinsfile' => 'Groovy',
148+ ];
149+
150+ /** #!のコマンド(後ろの数字を除く) => 言語 */
151+ private const INTERPRETERS = [
152+ 'sh' => 'Shell', 'bash' => 'Shell', 'zsh' => 'Shell', 'ksh' => 'Shell', 'mksh' => 'Shell', 'oksh' => 'Shell', 'pdksh' => 'Shell',
153+ 'dash' => 'Shell', 'ash' => 'Shell', 'rc' => 'Shell', 'fish' => 'Shell', 'python' => 'Python', 'pypy' => 'Python', 'ruby' => 'Ruby',
154+ 'jruby' => 'Ruby', 'perl' => 'Perl', 'raku' => 'Raku', 'php' => 'PHP', 'node' => 'JavaScript', 'nodejs' => 'JavaScript',
155+ 'bun' => 'JavaScript', 'deno' => 'TypeScript', 'lua' => 'Lua', 'luajit' => 'Lua', 'tclsh' => 'Tcl', 'wish' => 'Tcl', 'awk' => 'AWK',
156+ 'gawk' => 'AWK', 'nawk' => 'AWK', 'mawk' => 'AWK', 'rscript' => 'R', 'make' => 'Makefile', 'gmake' => 'Makefile', 'bmake' => 'Makefile',
157+ 'julia' => 'Julia', 'elixir' => 'Elixir', 'escript' => 'Erlang', 'guile' => 'Scheme', 'csi' => 'Scheme', 'racket' => 'Racket',
158+ 'sbcl' => 'Common Lisp', 'clisp' => 'Common Lisp', 'ecl' => 'Common Lisp', 'ocaml' => 'OCaml', 'runghc' => 'Haskell',
159+ 'runhaskell' => 'Haskell', 'crystal' => 'Crystal', 'pwsh' => 'PowerShell', 'dart' => 'Dart', 'swift' => 'Swift', 'groovy' => 'Groovy',
160+ 'scala' => 'Scala', 'emacs' => 'Emacs Lisp',
161+ ];
162+
163+ /** linguist-languageの別名(小文字) */
164+ private const ALIASES = [
165+ 'cpp' => 'C++', 'csharp' => 'C#', 'js' => 'JavaScript', 'ts' => 'TypeScript', 'sh' => 'Shell', 'bash' => 'Shell', 'golang' => 'Go',
166+ 'py' => 'Python', 'rb' => 'Ruby', 'objc' => 'Objective-C', 'objcpp' => 'Objective-C++', 'asm' => 'Assembly', 'make' => 'Makefile',
167+ 'vim' => 'Vim Script', 'viml' => 'Vim Script', 'elisp' => 'Emacs Lisp', 'md' => 'Markdown', 'yml' => 'YAML', 'vbnet' => 'Visual Basic .NET',
168+ 'terraform' => 'HCL', 'latex' => 'TeX', 'groff' => 'Roff', 'nroff' => 'Roff', 'manpage' => 'Roff',
169+ ];
170+
171+ /** 外部のコード(Linguistのvendor.ymlの主な物) */
172+ private const VENDORED = '~(^|/)(vendor|vendors|node_modules|bower_components|jspm_packages|third[_-]?party|3rd[_-]?party|externals?|extern|deps|'
173+ .'Pods|Carthage|Godeps|\.git|\.svn|\.hg|\.vscode|\.idea|\.yarn|\.bundle)/'
174+ .'|(^|/)(gradlew(\.bat)?|mvnw(\.cmd)?|config\.(guess|sub|status)|ltmain\.sh|install-sh|missing|depcomp|aclocal\.m4|Makefile\.in)$'
175+ .'|\.min\.(js|css)$|-min\.js$|(^|/)(jquery|bootstrap)[^/]*\.(js|css)$~i';
176+ /** ドキュメント */
177+ private const DOCUMENTATION = '~^(docs?|documentation|man|examples?|samples?|demos?)/|(^|/)(javadoc|groovydoc)/'
178+ .'|(^|/)(README|CHANGELOG|CHANGES|LICEN[CS]E|COPYING|CONTRIBUTING|INSTALL|NEWS|AUTHORS|THANKS|HISTORY)(\.[^/]*)?$~i';
179+ /** 生成されたファイル */
180+ private const GENERATED = '~(\.pb\.(go|cc|h)|_pb2\.py|\.designer\.cs|\.g\.dart|\.freezed\.dart)$'
181+ .'|(^|/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|composer\.lock|go\.sum)$~';
182+
183+ /** 言語の色(知らない言語は灰色) */
184+ public static function color(string $name): string {
185+ return self::LANGS[$name][1] ?? '#cccccc';
186+ }
187+
188+ /**
189+ * 言語毎のバイト数(保存した物が有ればそれ)
190+ *
191+ * @param bool $head 既定のブランチ(リポジトリの一覧に出す言語)
192+ * @return array|null 言語 => バイト数(多い順)。ファイルが多過ぎればnull
193+ */
194+ public static function get(Repository $repo, string $treeish, bool $head = false): ?array {
195+ $tree = $repo->treeOf($treeish);
196+ if ($tree === null) return null;
197+ $file = $repo->path.'/gitler-languages.json';
198+ $c = self::load($file);
199+ if (isset($c['trees'][$tree])) {
200+ if ($head && $c['head'] !== $tree) {
201+ $c['head'] = $tree;
202+ self::save($repo, $file, $c);
203+ }
204+ return $c['trees'][$tree];
205+ }
206+ $bytes = self::stats($repo, $tree);
207+ if ($bytes === null) return null;
208+ $c['trees'][$tree] = $bytes;
209+ if ($head) $c['head'] = $tree;
210+ // 古い物から消す(既定のブランチの物は残す)
211+ foreach (array_keys($c['trees']) as $k) {
212+ if (count($c['trees']) <= self::KEEP) break;
213+ if ($k !== $c['head'] && $k !== $tree) unset($c['trees'][$k]);
214+ }
215+ self::save($repo, $file, $c);
216+ return $bytes;
217+ }
218+
219+ /**
220+ * 既定のブランチの一番多い言語(保存した物だけ見る)
221+ *
222+ * @param string $dir リポジトリのディレクトリ
223+ * @return array|null name、color
224+ */
225+ public static function primary(string $dir): ?array {
226+ $c = self::load($dir.'/gitler-languages.json');
227+ $name = array_key_first($c['trees'][$c['head']] ?? []);
228+ return $name === null ? null : ['name' => (string)$name, 'color' => self::color((string)$name)];
229+ }
230+
231+ /**
232+ * 表示用(多い順、多過ぎれば残りは「その他」)
233+ *
234+ * @return array name、color、percent(「12.3%」)、width(CSSの幅)
235+ */
236+ public static function present(array $bytes): array {
237+ $total = array_sum($bytes);
238+ if ($total <= 0) return [];
239+ arsort($bytes);
240+ if (count($bytes) > self::SHOW) {
241+ $rest = array_slice($bytes, self::SHOW - 1, null, true);
242+ $bytes = array_slice($bytes, 0, self::SHOW - 1, true) + ['その他' => array_sum($rest)];
243+ }
244+ $out = [];
245+ foreach ($bytes as $name => $n) {
246+ $p = $n * 100 / $total;
247+ $out[] = ['name' => (string)$name, 'color' => $name === 'その他' ? '#746c75' : self::color((string)$name),
248+ 'percent' => $p < 0.1 ? '<0.1%' : number_format($p, 1).'%', 'width' => sprintf('%.3F', $p)];
249+ }
250+ return $out;
251+ }
252+
253+ /**
254+ * ツリーの言語毎のバイト数を数える
255+ *
256+ * @return array|null 言語 => バイト数(多い順)。ファイルが多過ぎればnull
257+ */
258+ public static function stats(Repository $repo, string $treeish): ?array {
259+ $root = $repo->treeOf($treeish);
260+ if ($root === null) return null;
261+ $rules = self::attributes($repo, $root);
262+ // 除外を取り消す.gitattributesが無ければ、除外するディレクトリの中は見ない
263+ $undo = false;
264+ foreach ($rules as [, $set]) {
265+ foreach (['linguist-vendored', 'linguist-generated', 'linguist-documentation'] as $k) if (array_key_exists($k, $set) && $set[$k] !== true) $undo = true;
266+ }
267+ $bytes = [];
268+ $files = 0;
269+ $stack = [['', $root]];
270+ while (!empty($stack)) {
271+ [$dir, $id] = array_pop($stack);
272+ foreach ($repo->tree($id)->entries as [$mode, $name, $eid]) {
273+ $path = $dir.$name;
274+ if ($mode === Tree::DIR) {
275+ if ($undo || !preg_match(self::VENDORED, $path.'/') && !preg_match(self::DOCUMENTATION, $path.'/')) $stack[] = [$path.'/', $eid];
276+ continue;
277+ }
278+ // リンクとサブモジュールは数えない
279+ if ($mode !== Tree::FILE && $mode !== Tree::EXEC) continue;
280+ if (++$files > self::MAX_FILES) return null;
281+ $size = 0;
282+ $lang = self::file($repo, $path, $mode, $eid, self::match($rules, $path), $size);
283+ if ($lang !== null) $bytes[$lang] = ($bytes[$lang] ?? 0) + $size;
284+ }
285+ }
286+ arsort($bytes);
287+ return $bytes;
288+ }
289+
290+ /**
291+ * 1つのファイルの言語(数えなければnull)
292+ *
293+ * @param array $a .gitattributesの属性
294+ */
295+ private static function file(Repository $repo, string $path, string $mode, string $id, array $a, int &$size): ?string {
296+ if ($a['linguist-vendored'] ?? (bool)preg_match(self::VENDORED, $path)) return null;
297+ if ($a['linguist-generated'] ?? (bool)preg_match(self::GENERATED, $path)) return null;
298+ if ($a['linguist-documentation'] ?? (bool)preg_match(self::DOCUMENTATION, $path)) return null;
299+ $size = (int)($repo->objects->info($id)[1] ?? 0);
300+ $base = basename($path);
301+ $dot = strrpos($base, '.');
302+ $ext = $dot === false || $dot === 0 ? '' : strtolower(substr($base, $dot + 1));
303+ $lang = isset($a['linguist-language']) ? self::named((string)$a['linguist-language']) : null;
304+ $lang ??= self::FILENAMES[$base] ?? null;
305+ if ($lang === null && preg_match('/^((GNU|BSD)?[Mm]akefile|Dockerfile|Containerfile)\./', $base, $m)) $lang = str_ends_with($m[1], 'akefile') ? 'Makefile' : 'Dockerfile';
306+ $lang ??= self::EXTENSIONS[$ext] ?? null;
307+ // 中身を見る:拡張子で決まらない物と、拡張子の無いスクリプト
308+ if ($lang === null && (isset(self::AMBIGUOUS[$ext]) || $ext === '' || $ext === 'cgi' || $mode === Tree::EXEC)) {
309+ if ($size > self::MAX_PEEK) {
310+ $lang = self::AMBIGUOUS[$ext] ?? null;
311+ } else {
312+ $data = $repo->blob($id);
313+ if (Diff::isBinary($data)) return null;
314+ // Autoconfのconfigure等は生成された物
315+ if (str_contains(substr($data, 0, 4096), 'Generated by GNU Autoconf')) return null;
316+ $lang = isset(self::AMBIGUOUS[$ext]) ? self::guess($ext, $data) : self::shebang($data);
317+ }
318+ }
319+ if ($lang === null) return null;
320+ $type = self::LANGS[$lang][0] ?? self::PROGRAMMING;
321+ return ($a['linguist-detectable'] ?? ($type === self::PROGRAMMING || $type === self::MARKUP)) ? $lang : null;
322+ }
323+
324+ /** linguist-languageの名前(大文字・小文字、空白・「-」・「_」は区別しない) */
325+ private static function named(string $value): string {
326+ $key = strtolower($value);
327+ if (isset(self::ALIASES[$key])) return self::ALIASES[$key];
328+ $norm = fn(string $s): string => str_replace([' ', '-', '_'], '', strtolower($s));
329+ foreach (array_keys(self::LANGS) as $name) if ($norm($name) === $norm($value)) return $name;
330+ return $value;
331+ }
332+
333+ /** 拡張子で決まらない物を中身で決める */
334+ private static function guess(string $ext, string $data): ?string {
335+ return match ($ext) {
336+ 'h' => preg_match('/^\s*(@interface|@implementation|@protocol|@end|#import)\b/m', $data) ? 'Objective-C'
337+ : (preg_match('/^\s*(class|namespace|template\s*<|using\s+namespace)\b|\bstd::|^\s*(public|private|protected)\s*:|#include\s*<(iostream|string|vector|map|memory|algorithm|cstd\w+)>/m', $data) ? 'C++' : 'C'),
338+ 'm' => preg_match('/^\s*(@interface|@implementation|@protocol|@end|#import|#include)\b/m', $data) ? 'Objective-C'
339+ : (preg_match('/^\s*(function\b|%)/m', $data) ? 'MATLAB' : 'Objective-C'),
340+ 'v' => preg_match('/^\s*module\s+\w+[\s\S]*^\s*endmodule\b/m', $data) ? 'Verilog'
341+ : (preg_match('/^\s*(Theorem|Lemma|Proof|Qed|Require\s+Import|Inductive)\b/m', $data) ? 'Coq' : 'V'),
342+ 'fs' => preg_match('/^\s*#version\s+\d+|\bgl_Frag\w+|^\s*(uniform|in|out)\s+(vec|float|int|mat|sampler)\w*\s+\w+\s*;/m', $data) ? 'GLSL' : 'F#',
343+ // makeの依存関係のファイル(生成された物)
344+ 'd' => preg_match('/^[^\s:#]+\.o\s*:/m', $data) ? null : 'D',
345+ default => self::AMBIGUOUS[$ext] ?? null,
346+ };
347+ }
348+
349+ /** #!から言語を決める(「#!/usr/bin/env -S php」等) */
350+ private static function shebang(string $data): ?string {
351+ if (!str_starts_with($data, '#!')) return null;
352+ $words = preg_split('/\s+/', trim(strtok(substr($data, 2), "\n")));
353+ $prog = basename((string)array_shift($words));
354+ if ($prog === 'env') {
355+ $prog = '';
356+ foreach ($words as $w) {
357+ if ($w === '' || $w[0] === '-' || str_contains($w, '=')) continue;
358+ $prog = basename($w);
359+ break;
360+ }
361+ }
362+ $prog = (string)preg_replace('/[\d.\-]+$/', '', strtolower($prog));
363+ return self::INTERPRETERS[$prog] ?? null;
364+ }
365+
366+ //////// .gitattributes
367+
368+ /**
369+ * ルートの.gitattributesのlinguist-*の属性
370+ *
371+ * @return array [正規表現, 属性 => true、false、null(未指定)又は言語]の配列
372+ */
373+ private static function attributes(Repository $repo, string $root): array {
374+ $e = $repo->tree($root)->find('.gitattributes');
375+ if ($e === null || ($e[0] !== Tree::FILE && $e[0] !== Tree::EXEC)) return [];
376+ $rules = [];
377+ foreach (preg_split('/\r?\n/', $repo->blob($e[2])) as $line) {
378+ $line = trim($line);
379+ if ($line === '' || $line[0] === '#') continue;
380+ $words = preg_split('/\s+/', $line);
381+ $pattern = (string)array_shift($words);
382+ $set = [];
383+ foreach ($words as $w) {
384+ if (!preg_match('/^([-!]?)(linguist-[a-z]+)(?:=(.*))?$/', $w, $m)) continue;
385+ if ($m[1] === '!') $set[$m[2]] = null;
386+ else if ($m[2] === 'linguist-language') $set[$m[2]] = $m[1] === '' && ($m[3] ?? '') !== '' ? $m[3] : null;
387+ else $set[$m[2]] = $m[1] === '-' ? false : !in_array(strtolower($m[3] ?? 'true'), ['false', '0', 'no'], true);
388+ }
389+ if (!empty($set)) $rules[] = [self::glob($pattern), $set];
390+ }
391+ return $rules;
392+ }
393+
394+ /** 後に書いた物が優先 */
395+ private static function match(array $rules, string $path): array {
396+ $a = [];
397+ foreach ($rules as [$re, $set]) if (preg_match($re, $path)) $a = array_merge($a, $set);
398+ return $a;
399+ }
400+
401+ /** .gitattributesのパターンを正規表現にする(「/」が無ければどの深さの名前にも一致する) */
402+ public static function glob(string $pattern): string {
403+ // ディレクトリだけのパターンはファイルに一致しない
404+ if (str_ends_with($pattern, '/')) return '~(?!)~';
405+ $anchored = str_contains($pattern, '/');
406+ if (str_starts_with($pattern, '/')) $pattern = substr($pattern, 1);
407+ $re = '';
408+ $len = strlen($pattern);
409+ for ($i = 0; $i < $len; $i++) {
410+ $ch = $pattern[$i];
411+ $start = $i === 0 || $pattern[$i - 1] === '/';
412+ if ($ch === '*' && $start && substr($pattern, $i, 3) === '**/') {
413+ $re .= '(?:.*/)?';
414+ $i += 2;
415+ } else if ($ch === '*' && $start && $i + 2 === $len && substr($pattern, $i, 2) === '**') {
416+ $re .= '.*';
417+ $i++;
418+ } else if ($ch === '*') {
419+ $re .= '[^/]*';
420+ } else if ($ch === '?') {
421+ $re .= '[^/]';
422+ } else if ($ch === '\\' && $i + 1 < $len) {
423+ $re .= preg_quote($pattern[++$i], '~');
424+ } else if ($ch === '[' && ($end = strpos($pattern, ']', $i + 2)) !== false) {
425+ $class = substr($pattern, $i + 1, $end - $i - 1);
426+ if ($class[0] === '!') $class = '^'.substr($class, 1);
427+ $re .= '['.str_replace(['\\', '~'], ['\\\\', '\~'], $class).']';
428+ $i = $end;
429+ } else {
430+ $re .= preg_quote($ch, '~');
431+ }
432+ }
433+ return '~^'.($anchored ? '' : '(?:.*/)?').$re.'$~';
434+ }
435+
436+ //////// 保存
437+
438+ private static function load(string $file): array {
439+ $c = json_decode((string)@file_get_contents($file), true);
440+ if (!is_array($c) || ($c['version'] ?? 0) !== self::VERSION || !is_array($c['trees'] ?? null)) return ['version' => self::VERSION, 'head' => '', 'trees' => []];
441+ $c['head'] = (string)($c['head'] ?? '');
442+ return $c;
443+ }
444+
445+ private static function save(Repository $repo, string $file, array $c): void {
446+ try {
447+ $repo->writeFile($file, (string)json_encode($c, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
448+ } catch (GitException) {
449+ // 保存出来なくても表示は出来る
450+ }
451+ }
452+}
@@ -48,6 +48,7 @@
4848 use Std\Lib\Gitler\Archive;
4949 use Std\Lib\Gitler\Gitler;
5050 use Std\Lib\Gitler\Keys;
51+use Std\Lib\Gitler\Languages;
5152 use Roles;
5253
5354 $test = new Tester([
@@ -329,6 +330,75 @@
329330 $z->close();
330331 @unlink(sys_get_temp_dir().'/lbgitler-test.zip');
331332 }
333+ });
334+});
335+
336+$test->describe('Gitler(言語の割合)', function($test) use ($base): void {
337+ $repo = Repository::init("{$base}/langs.git");
338+ $me = new Person('諏訪子', 's@x', 1700000000, '+0900');
339+ $files = [
340+ 'src/main.c' => str_repeat('c', 600), 'src/util.h' => "int f(void);\n".str_repeat('h', 87), 'src/app.hpp' => "class A {};\n",
341+ 'lib/x.h' => "namespace x {}\n".str_repeat('p', 86), 'index.php' => str_repeat('p', 300), 'style.css' => str_repeat('s', 100),
342+ 'view/a.maron' => str_repeat('m', 50), 'build' => "#!/usr/bin/env -S php-8.5\n".str_repeat('p', 74), 'run' => "#!/bin/sh\necho\n",
343+ 'Makefile' => "all:\n", 'README.md' => str_repeat('r', 5000), 'data.json' => str_repeat('j', 5000), 'logo.png' => "\x89PNG\0\0".random_bytes(100),
344+ 'vendor/lib.js' => str_repeat('v', 5000), 'node_modules/a/b.js' => str_repeat('v', 5000), 'docs/x.py' => str_repeat('d', 5000),
345+ 'app.min.js' => str_repeat('v', 5000), 'gen/x.pb.go' => str_repeat('g', 5000), 'empty' => "",
346+ 'third/keep.js' => str_repeat('k', 40), 'tools/x.txt' => str_repeat('t', 20), 'shaders/a.fs' => "#version 450\nvoid main() {}\n",
347+ ];
348+ $changes = [];
349+ foreach ($files as $p => $data) $changes[$p] = [in_array($p, ['build', 'run'], true) ? Tree::EXEC : Tree::FILE, $data];
350+ $changes['link.c'] = [Tree::LINK, 'src/main.c'];
351+ $tree = $repo->updateTree(null, $changes);
352+ $commit = $repo->writeCommit(Commit::create($tree, [], $me, $me, "x\n"));
353+
354+ $test->it('拡張子・名前・#!・中身で言語を決め、外部のコードとドキュメントとデータは数えないはず', function($test) use ($repo, $commit): void {
355+ $s = Languages::stats($repo, $commit);
356+ $test->assertSame(['C' => 700, 'PHP' => 400, 'C++' => 113, 'CSS' => 100, 'Maron' => 50, 'JavaScript' => 40, 'GLSL' => 28, 'Shell' => 15, 'Makefile' => 5], $s);
357+ });
358+
359+ $test->it('.gitattributesで変えられるはず', function($test) use ($repo, $changes, $me): void {
360+ $changes['.gitattributes'] = [Tree::FILE, "*.json linguist-detectable\nvendor/** -linguist-vendored\n/src/*.h linguist-language=cpp\n"
361+ ."index.php linguist-generated=true\nthird/** linguist-vendored\n*.txt linguist-language=Python\nview/[a-z].maron linguist-documentation\n"];
362+ $commit = $repo->writeCommit(Commit::create($repo->updateTree(null, $changes), [], $me, $me, "y\n"));
363+ $s = Languages::stats($repo, $commit);
364+ $test->assertSame(['JSON' => 5000, 'JavaScript' => 5000, 'C' => 600, 'C++' => 213, 'PHP' => 100, 'CSS' => 100, 'GLSL' => 28, 'Python' => 20, 'Shell' => 15, 'Makefile' => 5], $s);
365+ });
366+
367+ $test->it('パターンを正規表現にするはず', function($test): void {
368+ $m = fn(string $pat, string $path): bool => (bool)preg_match(Languages::glob($pat), $path);
369+ $test->assertTrue($m('*.c', 'a/b/x.c'));
370+ $test->assertFalse($m('/*.c', 'a/x.c'));
371+ $test->assertTrue($m('/*.c', 'x.c'));
372+ $test->assertTrue($m('a/**/x.c', 'a/x.c'));
373+ $test->assertTrue($m('a/**/x.c', 'a/b/c/x.c'));
374+ $test->assertTrue($m('**/x.c', 'q/x.c'));
375+ $test->assertTrue($m('a/**', 'a/b/c'));
376+ $test->assertFalse($m('a/*', 'a/b/c'));
377+ $test->assertTrue($m('[!a]?.c', 'bb.c'));
378+ $test->assertFalse($m('[!a]?.c', 'ab.c'));
379+ $test->assertFalse($m('a/', 'a/b'));
380+ });
381+
382+ $test->it('保存して、一覧には既定のブランチの一番多い言語を出すはず', function($test) use ($repo, $commit, $tree): void {
383+ $test->assertNull(Languages::primary($repo->path));
384+ $s = Languages::get($repo, $commit, true);
385+ $test->assertSame('C', array_key_first($s));
386+ $c = json_decode((string)file_get_contents($repo->path.'/gitler-languages.json'), true);
387+ $test->assertSame($tree, $c['head']);
388+ $test->assertSame($s, $c['trees'][$tree]);
389+ $test->assertSame(['name' => 'C', 'color' => '#555555'], Languages::primary($repo->path));
390+ $p = Languages::present($s);
391+ $test->assertSame('C', $p[0]['name']);
392+ $test->assertSame('48.2%', $p[0]['percent']);
393+ $p = Languages::present(['C' => 2000, 'D' => 1]);
394+ $test->assertSame(['99.950', '0.050'], array_column($p, 'width'));
395+ $test->assertSame(['100.0%', '<0.1%'], array_column($p, 'percent'));
396+ $many = [];
397+ foreach (['C', 'Go', 'Zig', 'PHP', 'Odin', 'Hare', 'Rust', 'Lua', 'Nim', 'Shell'] as $i => $l) $many[$l] = 100 - $i;
398+ $p = Languages::present($many);
399+ $test->assertSame(8, count($p));
400+ $test->assertSame('その他', $p[7]['name']);
401+ $test->assertSame([], Languages::present([]));
332402 });
333403 });
334404
@@ -11,7 +11,7 @@
1111 {@ if ($r['description'] !== '') @}
1212 <p>{{ $r['description'] }}</p>
1313 {@ endif @}
14- <small>更新:{{ $r['updated'] }}</small>
14+ <small>{@ if ($r['lang'] !== null) @}<span class="gl-langdot" style="background-color: {{ $r['lang']['color'] }};"></span>{{ $r['lang']['name'] }} {@ endif @}更新:{{ $r['updated'] }}</small>
1515 </li>
1616 {@ endforeach @}
1717 </ul>
@@ -2,6 +2,16 @@
22 {@ include(gitler/repohead) @}
33 {@ include(gitler/refbar) @}
44 {@ include(gitler/clone) @}
5+{@ if (!empty($langs)) @}
6+ <div class="gl-langs">
7+ <div class="gl-langbar">{@ foreach ($langs as $l) @}<span style="width: {{ $l['width'] }}%; background-color: {{ $l['color'] }};" title="{{ $l['name'] }} {{ $l['percent'] }}"></span>{@ endforeach @}</div>
8+ <ul class="gl-langlist">
9+{@ foreach ($langs as $l) @}
10+ <li><span class="gl-langdot" style="background-color: {{ $l['color'] }};"></span><b>{{ $l['name'] }}</b> {{ $l['percent'] }}</li>
11+{@ endforeach @}
12+ </ul>
13+ </div>
14+{@ endif @}
515 <div class="gl-last">
616 <a href="{{ $last['url'] }}">{{ $last['subject'] }}</a>
717 <small>{{ $last['author'] }}、{{ $last['date'] }}、<a href="{{ $last['url'] }}"><code>{{ $last['short'] }}</code></a></small>