@@ -248,6 +248,8 @@ fn check_readme_link_url(
248248 if url. contains ( "://" ) {
249249 // TODO: Should we check the URL here like for the `homepage` and
250250 // `repository` manifest fields?
251+
252+ check_repo_file_url ( diags, readme, sourcepos, url) ;
251253 } else if url. starts_with ( "#" ) {
252254 // TODO: Validate markdown anchor.
253255 } else {
@@ -257,9 +259,9 @@ fn check_readme_link_url(
257259 Diagnostic :: error ( )
258260 . with_code ( "readme/link/file-not-found" )
259261 . with_message ( format_args ! (
260- "Linked file not found: `{url}`. Make sure to commit all linked files, \
261- and possibly add them to the `exclude` list. \
262- More details: https://github.com/typst/packages/blob/main/docs/tips.md#what-to-commit-what-to-exclude",
262+ "Linked file not found: `{url}`.\n \n \
263+ Make sure to commit all linked files and possibly add them to the `exclude` list.\n \n \
264+ More details: https://github.com/typst/packages/blob/main/docs/tips.md#what-to-commit-what-to-exclude",
263265 ) )
264266 . with_labels ( vec ! [ Label :: primary(
265267 readme_fake_file_id( ) ,
@@ -270,6 +272,89 @@ fn check_readme_link_url(
270272 }
271273}
272274
275+ const DEFAULT_BRANCHES : [ & str ; 2 ] = [ "main" , "master" ] ;
276+
277+ fn check_repo_file_url (
278+ diags : & mut Diagnostics ,
279+ readme : & str ,
280+ sourcepos : Sourcepos ,
281+ url : & str ,
282+ ) -> Option < ( ) > {
283+ static GITHUB_URL : LazyLock < Regex > = LazyLock :: new ( || {
284+ Regex :: new ( r"https://github.com/([^/]+)/([^/]+)/(?:blob|tree)/([^/]+)/(.+)" ) . unwrap ( )
285+ } ) ;
286+ static GITHUB_RAW_URL : LazyLock < Regex > = LazyLock :: new ( || {
287+ Regex :: new (
288+ r"^https://raw.githubusercontent.com/([^/]+)/([^/]+)/(?:refs/heads/)?([^/]+)/(.+)$" ,
289+ )
290+ . unwrap ( )
291+ } ) ;
292+ static GITLAB_URL : LazyLock < Regex > = LazyLock :: new ( || {
293+ Regex :: new ( r"https://gitlab.com/([^/]+)/([^/]+)/-/(?:raw|blob|tree)/([^/]+)/(.+)" ) . unwrap ( )
294+ } ) ;
295+ static CODEBERG_URL : LazyLock < Regex > = LazyLock :: new ( || {
296+ Regex :: new ( r"https://codeberg.org/([^/]+)/([^/]+)/(?:raw|src)/branch/([^/]+)/(.+)" ) . unwrap ( )
297+ } ) ;
298+
299+ enum Host {
300+ Github ,
301+ Gitlab ,
302+ Codeberg ,
303+ }
304+
305+ let ( host, captures) = if let Some ( captures) =
306+ ( GITHUB_URL . captures ( url) ) . or_else ( || GITHUB_RAW_URL . captures ( url) )
307+ {
308+ ( Host :: Github , captures)
309+ } else if let Some ( captures) = GITLAB_URL . captures ( url) {
310+ ( Host :: Gitlab , captures)
311+ } else if let Some ( captures) = CODEBERG_URL . captures ( url) {
312+ ( Host :: Codeberg , captures)
313+ } else {
314+ return None ;
315+ } ;
316+
317+ let user = captures. get ( 1 ) . unwrap ( ) . as_str ( ) ;
318+ let repo = captures. get ( 2 ) . unwrap ( ) . as_str ( ) ;
319+ let branch = captures. get ( 3 ) . unwrap ( ) . as_str ( ) ;
320+ let path = captures. get ( 4 ) . unwrap ( ) . as_str ( ) ;
321+
322+ if !DEFAULT_BRANCHES . contains ( & branch) {
323+ return None ;
324+ }
325+
326+ let name = match host {
327+ Host :: Github => "GitHub" ,
328+ Host :: Gitlab => "Gitlab" ,
329+ Host :: Codeberg => "Codeberg" ,
330+ } ;
331+
332+ let non_raw_url = match host {
333+ Host :: Github => format ! ( "https://github.com/{user}/{repo}/blob/{branch}/{path}" ) ,
334+ Host :: Gitlab => format ! ( "https://gitlab.com/{user}/{repo}/-/blob/{branch}/{path}" ) ,
335+ Host :: Codeberg => format ! ( "https://codeberg.org/{user}/{repo}/src/branch/{branch}/{path}" ) ,
336+ } ;
337+
338+ diags. emit (
339+ Diagnostic :: warning ( )
340+ . with_code ( "readme/link/github-url-permalink" )
341+ . with_message ( format_args ! (
342+ "{name} URL links to default branch: `{url}`.\n \n \
343+ Consider using a link to a specific tag/release or a permalink to a commit instead. \
344+ This will ensure that the linked resource always matches this version of the package.\n \n \
345+ You can create a permalink here: {non_raw_url}\n \n \
346+ Alternatively you can also link to a local file. This is preferred if the linked file \
347+ is already present in the submitted package."
348+ ) )
349+ . with_label ( Label :: primary (
350+ readme_fake_file_id ( ) ,
351+ sourcepos_to_range ( readme, sourcepos) ,
352+ ) ) ,
353+ ) ;
354+
355+ Some ( ( ) )
356+ }
357+
273358fn check_image_alternative_description (
274359 diags : & mut Diagnostics ,
275360 readme : & str ,
0 commit comments