Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add edit_uri_template config #2927

Merged
merged 4 commits into from Sep 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
75 changes: 75 additions & 0 deletions docs/user-guide/configuration.md
Expand Up @@ -93,6 +93,24 @@ directory.
edit_uri: root/path/docs/
```

For example, having this config:

```yaml
repo_url: https://example.com/project/repo
edit_uri: blob/main/docs/
```

means that a page named 'foo/bar.md' will have its edit link lead to:
<https://example.com/project/repo/blob/main/docs/foo/bar.md>

`edit_uri` can actually be just an absolute URL, not necessarily relative to `repo_url`, so this can achieve the same result:

```yaml
repo_url: https://example.com/project/repo/blob/main/docs/
```

For more flexibility, see [edit_uri_template](#edit_uri_template) below.

> NOTE:
> On a few known hosts (specifically GitHub, Bitbucket and GitLab), the
> `edit_uri` is derived from the 'repo_url' and does not need to be set
Expand Down Expand Up @@ -124,6 +142,63 @@ access.
`src/default/docs/` for a Bitbucket repo, if `repo_url` matches those domains,
otherwise `null`

### edit_uri_template

The more flexible variant of [edit_uri](#edit_uri). These two are equivalent:

```yaml
edit_uri: 'blob/main/docs/'
edit_uri_template: 'blob/main/docs/{path}'
```

(they are also mutually exclusive -- don't specify both).

Starting from here, you can change the positioning or formatting of the path, in case the default behavior of appending the path isn't enough.

The contents of `edit_uri_template` are normal [Python format strings](https://docs.python.org/3/library/string.html#formatstrings), with only these fields available:

* `{path}`, e.g. `foo/bar.md`
* `{path_noext}`, e.g. `foo/bar`

And the conversion flag `!q` is available, to percent-encode the field:

* `{path!q}`, e.g. `foo%2Fbar.md`

Here are some suggested configurations that can be useful:

GitHub Wiki:
(e.g. `https://github.com/project/repo/wiki/foo/bar/_edit`)

```yaml
repo_url: 'https://github.com/project/repo/wiki'
edit_uri_template: '{path_noext}/_edit'
```

BitBucket editor:
(e.g. `https://bitbucket.org/project/repo/src/master/docs/foo/bar.md?mode=edit`)

```yaml
repo_url: 'https://bitbucket.org/project/repo/'
edit_uri_template: 'src/master/docs/{path}?mode=edit'
```

GitLab Static Site Editor:
(e.g. `https://gitlab.com/project/repo/-/sse/master/docs%2Ffoo%2bar.md`)

```yaml
repo_url: 'https://gitlab.com/project/repo'
edit_uri_template: '-/sse/master/docs%2F{path!q}'
```

GitLab Web IDE:
(e.g. `https://gitlab.com/-/ide/project/repo/edit/master/-/docs/foo/bar.md`)

```yaml
edit_uri_template: 'https://gitlab.com/-/ide/project/repo/edit/master/-/docs/{path}'
```

**default**: `null`

### site_description

Set the site description. This will add a meta tag to the generated HTML header.
Expand Down
97 changes: 91 additions & 6 deletions mkdocs/config/config_options.py
Expand Up @@ -2,10 +2,14 @@

import ipaddress
import os
import string
import sys
import traceback
import typing as t
import warnings
from collections import UserString
from typing import NamedTuple
from urllib.parse import quote as urlquote
from urllib.parse import urlsplit, urlunsplit

import markdown
Expand Down Expand Up @@ -347,12 +351,11 @@ def run_validation(self, value):


class RepoURL(URL):
"""
Repo URL Config Option

A small extension to the URL config that sets the repo_name and edit_uri,
based on the url if they haven't already been provided.
"""
def __init__(self, *args, **kwargs):
warnings.warn(
"RepoURL is no longer used in MkDocs and will be removed.", DeprecationWarning
)
super().__init__(*args, **kwargs)

def post_validation(self, config, key_name):
repo_host = urlsplit(config['repo_url']).netloc.lower()
Expand Down Expand Up @@ -385,6 +388,88 @@ def post_validation(self, config, key_name):
config['edit_uri'] = edit_uri


class EditURI(Type):
def __init__(self, repo_url_key):
super().__init__(str)
self.repo_url_key = repo_url_key

def post_validation(self, config, key_name):
edit_uri = config.get(key_name)
repo_url = config.get(self.repo_url_key)

if edit_uri is None and repo_url is not None:
repo_host = urlsplit(repo_url).netloc.lower()
if repo_host == 'github.com' or repo_host == 'gitlab.com':
edit_uri = 'edit/master/docs/'
elif repo_host == 'bitbucket.org':
edit_uri = 'src/default/docs/'

# ensure a well-formed edit_uri
if edit_uri and not edit_uri.endswith('/'):
edit_uri += '/'

config[key_name] = edit_uri


class EditURITemplate(OptionallyRequired):
class Formatter(string.Formatter):
def convert_field(self, value, conversion):
if conversion == 'q':
return urlquote(value, safe='')
return super().convert_field(value, conversion)

class Template(UserString):
def __init__(self, formatter, data):
super().__init__(data)
self.formatter = formatter
try:
self.format('', '')
except KeyError as e:
raise ValueError(f"Unknown template substitute: {e}")

def format(self, path, path_noext):
return self.formatter.format(self.data, path=path, path_noext=path_noext)

def __init__(self, edit_uri_key=None):
super().__init__()
self.edit_uri_key = edit_uri_key

def run_validation(self, value):
try:
return self.Template(self.Formatter(), value)
except Exception as e:
raise ValidationError(e)

def post_validation(self, config, key_name):
if self.edit_uri_key and config.get(key_name) and config.get(self.edit_uri_key):
self.warnings.append(
f"The option '{self.edit_uri_key}' has no effect when '{key_name}' is set."
)


class RepoName(Type):
def __init__(self, repo_url_key):
super().__init__(str)
self.repo_url_key = repo_url_key

def post_validation(self, config, key_name):
repo_name = config.get(key_name)
repo_url = config.get(self.repo_url_key)

# derive repo_name from repo_url if unset
if repo_url is not None and repo_name is None:
repo_host = urlsplit(config['repo_url']).netloc.lower()
if repo_host == 'github.com':
repo_name = 'GitHub'
elif repo_host == 'bitbucket.org':
repo_name = 'Bitbucket'
elif repo_host == 'gitlab.com':
repo_name = 'GitLab'
else:
repo_name = repo_host.split('.')[0].title()
config[key_name] = repo_name


class FilesystemObject(Type):
"""
Base class for options that point to filesystem objects.
Expand Down
7 changes: 4 additions & 3 deletions mkdocs/config/defaults.py
Expand Up @@ -63,17 +63,18 @@ class _MkDocsConfig:
True generates nicer URLs, but False is useful if browsing the output on
a filesystem."""

repo_url = config_options.RepoURL()
repo_url = config_options.URL()
"""Specify a link to the project source repo to be included
in the documentation pages."""

repo_name = config_options.Type(str)
repo_name = config_options.RepoName('repo_url')
"""A name to use for the link to the project source repo.
Default, If repo_url is unset then None, otherwise
"GitHub", "Bitbucket" or "GitLab" for known url or Hostname
for unknown urls."""

edit_uri = config_options.Type(str)
edit_uri_template = config_options.EditURITemplate('edit_uri')
edit_uri = config_options.EditURI('repo_url')
"""Specify a URI to the docs dir in the project source repo, relative to the
repo_url. When set, a link directly to the page in the source repo will
be added to the generated HTML. If repo_url is not set also, this option
Expand Down
20 changes: 16 additions & 4 deletions mkdocs/structure/pages.py
Expand Up @@ -41,7 +41,9 @@ def __init__(self, title: Optional[str], file: File, config: Config) -> None:
self.update_date = get_build_date()

self._set_canonical_url(config.get('site_url', None))
self._set_edit_url(config.get('repo_url', None), config.get('edit_uri', None))
self._set_edit_url(
config.get('repo_url', None), config.get('edit_uri'), config.get('edit_uri_template')
)

# Placeholders to be filled in later in the build process.
self.markdown = None
Expand Down Expand Up @@ -172,10 +174,20 @@ def _set_canonical_url(self, base: Optional[str]) -> None:
self.canonical_url = None
self.abs_url = None

def _set_edit_url(self, repo_url: Optional[str], edit_uri: Optional[str]) -> None:
if edit_uri:
def _set_edit_url(
self,
repo_url: Optional[str],
edit_uri: Optional[str] = None,
edit_uri_template: Optional[str] = None,
) -> None:
if edit_uri or edit_uri_template:
src_uri = self.file.src_uri
edit_uri += src_uri
if edit_uri_template:
noext = posixpath.splitext(src_uri)[0]
edit_uri = edit_uri_template.format(path=src_uri, path_noext=noext)
else:
assert edit_uri is not None and edit_uri.endswith('/')
ultrabug marked this conversation as resolved.
Show resolved Hide resolved
edit_uri += src_uri
if repo_url:
# Ensure urljoin behavior is correct
if not edit_uri.startswith(('?', '#')) and not repo_url.endswith('/'):
Expand Down
65 changes: 60 additions & 5 deletions mkdocs/tests/config/config_options_tests.py
Expand Up @@ -417,11 +417,12 @@ class Schema:
self.get_config(Schema, {'option': 1})


class RepoURLTest(TestCase):
class EditURITest(TestCase):
class Schema:
repo_url = config_options.RepoURL()
repo_name = config_options.Type(str)
edit_uri = config_options.Type(str)
repo_url = config_options.URL()
repo_name = config_options.RepoName('repo_url')
edit_uri_template = config_options.EditURITemplate('edit_uri')
edit_uri = config_options.EditURI('repo_url')

def test_repo_name_github(self):
config = self.get_config(
Expand Down Expand Up @@ -479,7 +480,7 @@ def test_edit_uri_custom(self):
self.Schema,
{'repo_url': "https://launchpad.net/python-tuskarclient"},
)
self.assertEqual(config.get('edit_uri'), '')
self.assertEqual(config.get('edit_uri'), None)
self.assertEqual(config['repo_url'], "https://launchpad.net/python-tuskarclient")

def test_repo_name_custom_and_empty_edit_uri(self):
Expand All @@ -489,6 +490,60 @@ def test_repo_name_custom_and_empty_edit_uri(self):
)
self.assertEqual(config.get('edit_uri'), 'edit/master/docs/')

def test_edit_uri_template_ok(self):
config = self.get_config(
self.Schema,
{
'repo_url': "https://github.com/mkdocs/mkdocs",
'edit_uri_template': 'edit/foo/docs/{path}',
},
)
self.assertEqual(config['edit_uri_template'], 'edit/foo/docs/{path}')

def test_edit_uri_template_errors(self):
with self.expect_error(
edit_uri_template=re.compile(r'.*[{}].*') # Complains about unclosed '{' or missing '}'
):
self.get_config(
self.Schema,
{
'repo_url': "https://github.com/mkdocs/mkdocs",
'edit_uri_template': 'edit/master/{path',
},
)

with self.expect_error(edit_uri_template=re.compile(r'.*\bz\b.*')):
self.get_config(
self.Schema,
{
'repo_url': "https://github.com/mkdocs/mkdocs",
'edit_uri_template': 'edit/master/{path!z}',
},
)

with self.expect_error(edit_uri_template="Unknown template substitute: 'foo'"):
self.get_config(
self.Schema,
{
'repo_url': "https://github.com/mkdocs/mkdocs",
'edit_uri_template': 'edit/master/{foo}',
},
)

def test_edit_uri_template_warning(self):
config = self.get_config(
self.Schema,
{
'repo_url': "https://github.com/mkdocs/mkdocs",
'edit_uri': 'edit',
'edit_uri_template': 'edit/master/{path}',
},
warnings=dict(
edit_uri_template="The option 'edit_uri' has no effect when 'edit_uri_template' is set."
),
)
self.assertEqual(config['edit_uri_template'], 'edit/master/{path}')


class ListOfItemsTest(TestCase):
def test_int_type(self):
Expand Down