Skip to content

Commit

Permalink
Expand config.extra_javascript to support type, async, defer
Browse files Browse the repository at this point in the history
The top-level template object `extra_javascript` gets soft-deprecated because it still reports only a list of plain strings.

Themes need to take action and pick up the new config keys from `config.extra_javascript` instead.
  • Loading branch information
oprypin committed Jun 3, 2023
1 parent 566c666 commit 91f0646
Show file tree
Hide file tree
Showing 15 changed files with 286 additions and 37 deletions.
69 changes: 68 additions & 1 deletion docs/dev-guide/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,16 @@ The simplest `main.html` file is the following:
<html>
<head>
<title>{% if page.title %}{{ page.title }} - {% endif %}{{ config.site_name }}</title>
{%- for path in config.extra_css %}
<link href="{{ path | url }}" rel="stylesheet">
{%- endfor %}
</head>
<body>
{{ page.content }}
{%- for script in config.extra_javascript %}
{{ script | script_tag }}
{%- endfor %}
</body>
</html>
```
Expand All @@ -116,6 +123,58 @@ themes for consistency.
[template inheritance]: https://jinja.palletsprojects.com/en/latest/templates/#template-inheritance
[blocks]: ../user-guide/customizing-your-theme.md#overriding-template-blocks

### Picking up CSS and JavaScript from the config

MkDocs defines the top-level [extra_css](../user-guide/configuration.md#extra_css) and [extra_javascript](../user-guide/configuration.md#extra_javascript) configs. These are lists of files.

The theme must include the HTML that links the items from these configs, otherwise the configs will be non-functional. You can see the recommended way to render both of them in the [base example above](#basic-theme).

> NEW: **Changed in version 1.5:**
>
> The items of the `config.extra_javascript` list used to be simple strings but now became objects that have these fields: `path`, `type`, `async`, `defer`.
>
> In that version, MkDocs also gained the [`script_tag` filter](#script_tag).
>
> >? EXAMPLE: **Obsolete style:**
> >
> > ```django
> > {%- for path in extra_javascript %}
> > <script src="{{ path }}"></script>
> > {%- endfor %}
> > ```
> >
> > This old-style example even uses the obsolete top-level `extra_javascript` list. Please always use `config.extra_javascript` instead.
> >
> > So, a slightly more modern approach is the following, but it is still obsolete because it ignores the extra attributes of the script:
> >
> > ```django
> > {%- for path in config.extra_javascript %}
> > <script src="{{ path | url }}"></script>
> > {%- endfor %}
> > ```
> <!-- -->
> >? EXAMPLE: **New style:**
> >
> > ```django
> > {%- for script in config.extra_javascript %}
> > {{ script | script_tag }}
> > {%- endfor %}
> > ```
>
> If you wish to be able to pick up the new customizations while keeping your theme compatible with older versions of MkDocs, use this snippet:
>
> >! EXAMPLE: **Backwards-compatible style:**
> >
> > ```django
> > {%- for script in config.extra_javascript %}
> > {%- if script.path %} {# Detected MkDocs 1.5+ which has `script.path` and `script_tag` #}
> > {{ script | script_tag }}
> > {%- else %} {# Fallback - examine the file name directly #}
> > <script src="{{ script | url }}"{% if script.endswith(".mjs") %} type="module"{% endif %}></script>
> > {%- endif %}
> > {%- endfor %}
> > ```
## Theme Files

There are various files which a theme treats special in some way. Any other
Expand Down Expand Up @@ -227,7 +286,7 @@ Following is a basic usage example which outputs the first and second level
navigation as a nested list.

```django
{% if nav|length>1 %}
{% if nav|length > 1 %}
<ul>
{% for nav_item in nav %}
{% if nav_item.children %}
Expand Down Expand Up @@ -622,6 +681,14 @@ Safely convert a Python object to a value in a JavaScript script.
</script>
```

### script_tag

NEW: **New in version 1.5.**

Convert an item from `extra_javascript` to a `<script>` tag that takes into account all [customizations of this config](../user-guide/configuration.md#extra_javascript) and has the equivalent of [`|url`](#url) behavior built-in.

See how to use it in the [base example above](#basic-theme)

## Search and themes

As of MkDocs version *0.17* client side search support has been added to MkDocs
Expand Down
43 changes: 36 additions & 7 deletions docs/user-guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,25 +386,54 @@ the root of your local file system.
### extra_css

Set a list of CSS files in your `docs_dir` to be included by the theme. For
example, the following example will include the extra.css file within the
css subdirectory in your [docs_dir](#docs_dir).
Set a list of CSS files (relative to `docs_dir`) to be included by the theme, typically as `<link>` tags.

Example:

```yaml
extra_css:
- css/extra.css
- css/second_extra.css
- css/extra.css
- css/second_extra.css
```

**default**: `[]` (an empty list).

### extra_javascript

Set a list of JavaScript files in your `docs_dir` to be included by the theme.
See the example in [extra_css] for usage.
Set a list of JavaScript files in your `docs_dir` to be included by the theme, as `<script>` tags.

> NEW: **Changed in version 1.5:**
>
> Older versions of MkDocs supported only a plain list strings, but now several additional config keys are available: `type`, `async`, `defer`
See the examples and what they produce:

```yaml
extra_javascript:
- some_plain_javascript.js # <script src="some_plain_javascript.js"></script>
# New behavior in MkDocs 1.5:
- implicitly_as_module.mjs # <script src="implicitly_as_module.mjs" type="module"></script>
# Config keys only supported since MkDocs 1.5:
- path: explicitly_as_module.mjs # <script src="explicitly_as_module.mjs" type="module"></script>
type: module
- path: deferred_plain.js # <script src="deferred_plain.js" deferred></script>
defer: true
- path: scripts/async_module.mjs # <script src="scripts/async_module.mjs" type="module" async></script>
type: module
async: true
```

So, each item can be either:

* a plain string, or
* a mapping that has the required `path` key and 3 optional keys `type` (string), `async` (boolean), `defer` (boolean).

Only the plain string variant detects the `.mjs` extension and adds `type="module"`, otherwise `type: module` must be written out regardless of extension.

**default**: `[]` (an empty list).

NOTE: `*.js` and `*.css` files, just like any other type of file, are always copied from `docs_dir` into the site's deployed copy, regardless if they're added to the pages via the above configs or not.

### extra_templates

Set a list of templates in your `docs_dir` to be built by MkDocs. To see more
Expand Down
17 changes: 7 additions & 10 deletions docs/user-guide/customizing-your-theme.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ need to include either CSS or JavaScript files within your [documentation
directory].

For example, to change the color of the headers in your documentation, create
a file called `extra.css` and place it next to the documentation Markdown. In
a file called (for example) `style.css` and place it next to the documentation Markdown. In
that file add the following CSS.

```css
Expand All @@ -27,14 +27,12 @@ h1 {
}
```

> NOTE:
> If you are deploying your documentation with [ReadTheDocs], you will need
> to explicitly list the CSS and JavaScript files you want to include in
> your config. To do this, add the following to your mkdocs.yml.
>
> ```yaml
> extra_css: [extra.css]
> ```
Then you need to add it to `mkdocs.yml`:

```yaml
extra_css:
- style.css
```

After making these changes, they should be visible when you run
`mkdocs serve` - if you already had this running, you should see that the CSS
Expand Down Expand Up @@ -218,7 +216,6 @@ any additional CSS files included in the `custom_dir`.
[extra_css]: ./configuration.md#extra_css
[extra_javascript]: ./configuration.md#extra_javascript
[documentation directory]: ./configuration.md#docs_dir
[ReadTheDocs]: ./deploying-your-docs.md#readthedocs
[custom_dir]: ./configuration.md#custom_dir
[name]: ./configuration.md#name
[mkdocs]: ./choosing-your-theme.md#mkdocs
Expand Down
7 changes: 4 additions & 3 deletions mkdocs/commands/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ def get_context(
if page is not None:
base_url = utils.get_relative_url('.', page.url)

extra_javascript = utils.create_media_urls(config.extra_javascript, page, base_url)

extra_css = utils.create_media_urls(config.extra_css, page, base_url)
extra_javascript = [
utils.normalize_url(str(script), page, base_url) for script in config.extra_javascript
]
extra_css = [utils.normalize_url(path, page, base_url) for path in config.extra_css]

if isinstance(files, Files):
files = files.documentation_pages()
Expand Down
29 changes: 29 additions & 0 deletions mkdocs/config/config_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,35 @@ def run_validation(self, value: object) -> None:
raise ValidationError('For internal use only.')


class ExtraScriptValue(Config):
"""An extra script to be added to the page. The `extra_javascript` config is a list of these."""

path = Type(str)
"""The value of the `src` tag of the script."""
type = Type(str, default='')
"""The value of the `type` tag of the script."""
defer = Type(bool, default=False)
"""Whether to add the `defer` tag to the script."""
async_ = Type(bool, default=False)
"""Whether to add the `async` tag to the script."""

def __init__(self, path: str = ''):
super().__init__()
self.path = path

def __str__(self):
return self.path


class ExtraScript(SubConfig[ExtraScriptValue]):
def run_validation(self, value: object) -> ExtraScriptValue:
if isinstance(value, str):
return super().run_validation(
{'path': value, 'type': 'module' if value.endswith('.mjs') else ''}
)
return super().run_validation(value)


class MarkdownExtensions(OptionallyRequired[List[str]]):
"""
Markdown Extensions Config Option
Expand Down
2 changes: 1 addition & 1 deletion mkdocs/config/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ class MkDocsConfig(base.Config):
is ignored."""

extra_css = c.Type(list, default=[])
extra_javascript = c.Type(list, default=[])
extra_javascript = c.ListOfItems(c.ExtraScript(), default=[])
"""Specify which css or javascript files from the docs directory should be
additionally included in the site."""

Expand Down
2 changes: 1 addition & 1 deletion mkdocs/contrib/search/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def on_config(self, config: MkDocsConfig, **kwargs) -> MkDocsConfig:
path = os.path.join(base_path, 'templates')
config.theme.dirs.append(path)
if 'search/main.js' not in config.extra_javascript:
config.extra_javascript.append('search/main.js')
config.extra_javascript.append('search/main.js') # type: ignore
if self.config.lang is None:
# lang setting undefined. Set default based on theme locale
validate = _PluginConfig.lang.run_validation
Expand Down
59 changes: 59 additions & 0 deletions mkdocs/tests/config/config_options_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -692,6 +692,65 @@ class Schema(Config):
)


class ExtraScriptsTest(TestCase):
def test_js_async(self) -> None:
class Schema(Config):
option = c.ListOfItems(c.ExtraScript(), default=[])

conf = self.get_config(Schema, {'option': ['foo.js', {'path': 'bar.js', 'async': True}]})
assert_type(conf.option, List[c.ExtraScriptValue])
self.assertEqual(len(conf.option), 2)
self.assertIsInstance(conf.option[0], c.ExtraScriptValue)
self.assertEqual(
[(x.path, x.type, x.defer, x.async_) for x in conf.option],
[
('foo.js', '', False, False),
('bar.js', '', False, True),
],
)

def test_mjs(self) -> None:
class Schema(Config):
option = c.ListOfItems(c.ExtraScript(), default=[])

conf = self.get_config(
Schema, {'option': ['foo.mjs', {'path': 'bar.js', 'type': 'module'}]}
)
assert_type(conf.option, List[c.ExtraScriptValue])
self.assertEqual(len(conf.option), 2)
self.assertIsInstance(conf.option[0], c.ExtraScriptValue)
self.assertEqual(
[(x.path, x.type, x.defer, x.async_) for x in conf.option],
[
('foo.mjs', 'module', False, False),
('bar.js', 'module', False, False),
],
)

def test_wrong_type(self) -> None:
class Schema(Config):
option = c.ListOfItems(c.ExtraScript(), default=[])

with self.expect_error(
option="The configuration is invalid. Expected a key-value mapping (dict) but received: <class 'int'>"
):
self.get_config(Schema, {'option': [1]})

def test_unknown_key(self) -> None:
class Schema(Config):
option = c.ListOfItems(c.ExtraScript(), default=[])

conf = self.get_config(
Schema,
{'option': [{'path': 'foo.js', 'foo': 'bar'}]},
warnings=dict(option="Sub-option 'foo': Unrecognised configuration name: foo"),
)
self.assertEqual(
[(x.path, x.type, x.defer, x.async_) for x in conf.option],
[('foo.js', '', False, False)],
)


class FilesystemObjectTest(TestCase):
def test_valid_dir(self) -> None:
for cls in c.Dir, c.FilesystemObject:
Expand Down
49 changes: 49 additions & 0 deletions mkdocs/tests/utils/templates_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import unittest
from textwrap import dedent

import yaml

from mkdocs.tests.base import load_config
from mkdocs.utils import templates


class UtilsTemplatesTests(unittest.TestCase):
def test_script_tag(self):
cfg_yaml = dedent(
'''
extra_javascript:
- some_plain_javascript.js
- implicitly_as_module.mjs
- path: explicitly_as_module.mjs
type: module
- path: deferred_plain.js
defer: true
- path: scripts/async_module.mjs
type: module
async: true
- path: 'aaaaaa/"my script".mjs'
type: module
async: true
defer: true
- path: plain.mjs
'''
)
config = load_config(**yaml.safe_load(cfg_yaml))
config.extra_javascript.append('plain_string.mjs')

self.assertEqual(
[
str(templates.script_tag_filter({'page': None, 'base_url': 'here'}, item))
for item in config.extra_javascript
],
[
'<script src="here/some_plain_javascript.js"></script>',
'<script src="here/implicitly_as_module.mjs" type="module"></script>',
'<script src="here/explicitly_as_module.mjs" type="module"></script>',
'<script src="here/deferred_plain.js" defer></script>',
'<script src="here/scripts/async_module.mjs" type="module" async></script>',
'<script src="here/aaaaaa/&#34;my script&#34;.mjs" type="module" defer async></script>',
'<script src="here/plain.mjs"></script>',
'<script src="here/plain_string.mjs"></script>',
],
)
1 change: 1 addition & 0 deletions mkdocs/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,5 +117,6 @@ def get_env(self) -> jinja2.Environment:
# No autoreload because editing a template in the middle of a build is not useful.
env = jinja2.Environment(loader=loader, auto_reload=False)
env.filters['url'] = templates.url_filter
env.filters['script_tag'] = templates.script_tag_filter
localization.install_translations(env, self._vars['locale'], self.dirs)
return env
Loading

0 comments on commit 91f0646

Please sign in to comment.