0; * pass 0 to omit the anchor (whole-file links). */ public function view_url( array $parts, int $line_start, int $line_end ): string; } /** * Registry + shared helpers. Detection is first-match-wins over the provider list, * which is filterable via `csdt_git_providers` so future providers (Bitbucket, * self-hosted Gitea hosts) can be added without editing this file. */ final class CSDT_Git_Providers { /** @var CSDT_Git_Provider[]|null */ private static ?array $registry = null; /** * Lazily builds and caches the provider list. * * @return CSDT_Git_Provider[] */ private static function registry(): array { if ( self::$registry === null ) { $providers = array( new CSDT_Provider_GitHub(), new CSDT_Provider_GitLab(), ); /** * Filter the registered git providers. Order matters: detection returns * the first provider whose matches() is true. * * @param CSDT_Git_Provider[] $providers */ self::$registry = apply_filters( 'csdt_git_providers', $providers ); } return self::$registry; } /** Returns the first provider that recognises the URL, or null. */ public static function detect( string $url ): ?CSDT_Git_Provider { foreach ( self::registry() as $p ) { if ( $p->matches( $url ) ) { return $p; } } return null; } /** Look up a provider by its slug. */ public static function by_id( string $id ): ?CSDT_Git_Provider { foreach ( self::registry() as $p ) { if ( $p->id() === $id ) { return $p; } } return null; } /** rawurlencode each path segment but keep the slashes between them. */ public static function encode_path( string $path ): string { return implode( '/', array_map( 'rawurlencode', explode( '/', $path ) ) ); } /** Pull a #snippet=NAME hint out of a URL hash fragment. */ public static function snippet_from_hash( string $hash ): string { if ( preg_match( '/(?:^|[&;])snippet=([A-Za-z0-9_.\-]+)/', $hash, $sm ) ) { return $sm[1]; } return ''; } } /** * GitHub provider. Accepts a GitHub "blob" page URL (owner, repo, branch, path and * an optional #L42-L87 line range) and fetches the same file through GitHub's * raw-content host, which is fixed by GitHub and disclosed in readme.txt. */ final class CSDT_Provider_GitHub implements CSDT_Git_Provider { public function id(): string { return 'github'; } public function label(): string { return 'GitHub'; } public function matches( string $url ): bool { return (bool) preg_match( '~^https?://github\.com/~', trim( $url ) ); } public function parse( string $url ): ?array { $url = trim( $url ); if ( ! preg_match( '~^https?://github\.com/([^/\s]+)/([^/\s]+)/blob/([^/\s]+)/([^#\s]+)(?:#(.*))?$~', $url, $m ) ) { return null; } return array( 'host' => 'github.com', 'owner' => $m[1], 'repo' => $m[2], 'branch' => rawurldecode( $m[3] ), 'path' => rawurldecode( $m[4] ), 'snippet' => CSDT_Git_Providers::snippet_from_hash( $m[5] ?? '' ), ); } public function raw_url( array $p ): string { // GitHub's REST contents endpoint. Asked for with the raw media type (see // CSDT_GitHub_Sync::fetch_url()), it returns the file body itself. The ref travels // as a query argument, so a branch name containing a slash resolves correctly. return sprintf( 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', rawurlencode( $p['owner'] ), rawurlencode( $p['repo'] ), CSDT_Git_Providers::encode_path( $p['path'] ), rawurlencode( $p['branch'] ) ); } public function view_url( array $p, int $line_start, int $line_end ): string { $url = sprintf( 'https://github.com/%s/%s/blob/%s/%s', rawurlencode( $p['owner'] ), rawurlencode( $p['repo'] ), rawurlencode( $p['branch'] ), CSDT_Git_Providers::encode_path( $p['path'] ) ); if ( $line_start > 0 ) { $url .= '#L' . $line_start . '-L' . $line_end; } return $url; } } /** * GitLab provider, works on GitLab's hosted service AND self-hosted instances. GitLab's * `/-/blob/` path segment is distinctive, so we recognise it on any host rather * than matching a fixed hostname. Owner may contain nested subgroups * (group/subgroup/repo), which we preserve. * * view: https://{host}/{owner}/{repo}/-/blob/{branch}/{path}#L42-87 * raw: https://{host}/{owner}/{repo}/-/raw/{branch}/{path} * * Note: only PUBLIC repos are supported (unauthenticated raw fetch). Private / * token-gated instances are intentionally out of scope. */ final class CSDT_Provider_GitLab implements CSDT_Git_Provider { public function id(): string { return 'gitlab'; } public function label(): string { return 'GitLab'; } public function matches( string $url ): bool { return (bool) preg_match( '~^https?://[^/\s]+/.+/-/blob/~', trim( $url ) ); } public function parse( string $url ): ?array { $url = trim( $url ); // owner/repo is everything before `/-/blob/`; capture lazily then split off // the final segment as the repo so nested subgroups stay in the owner. if ( ! preg_match( '~^https?://([^/\s]+)/(.+?)/-/blob/([^/\s]+)/([^#\s]+)(?:#(.*))?$~', $url, $m ) ) { return null; } $ns = explode( '/', $m[2] ); $repo = array_pop( $ns ); $owner = implode( '/', $ns ); if ( $owner === '' || $repo === '' ) { return null; } return array( 'host' => $m[1], 'owner' => $owner, 'repo' => $repo, 'branch' => rawurldecode( $m[3] ), 'path' => rawurldecode( $m[4] ), 'snippet' => CSDT_Git_Providers::snippet_from_hash( $m[5] ?? '' ), ); } public function raw_url( array $p ): string { return sprintf( // Host is emitted verbatim, it may carry a :port and is already // constrained to a single hostname token by parse()'s regex. 'https://%s/%s/%s/-/raw/%s/%s', $p['host'], CSDT_Git_Providers::encode_path( $p['owner'] ), rawurlencode( $p['repo'] ), rawurlencode( $p['branch'] ), CSDT_Git_Providers::encode_path( $p['path'] ) ); } public function view_url( array $p, int $line_start, int $line_end ): string { $url = sprintf( 'https://%s/%s/%s/-/blob/%s/%s', $p['host'], CSDT_Git_Providers::encode_path( $p['owner'] ), rawurlencode( $p['repo'] ), rawurlencode( $p['branch'] ), CSDT_Git_Providers::encode_path( $p['path'] ) ); if ( $line_start > 0 ) { // GitLab uses a single-dash line range, e.g. #L42-87. $url .= '#L' . $line_start . '-' . $line_end; } return $url; } }