1# -----------------------------------------------------------------------------
2# Copyright (c) 2012 - 2021, Anaconda, Inc., and Bokeh Contributors.
3# All rights reserved.
4#
5# The full license is in the file LICENSE.txt, distributed with this software.
6# -----------------------------------------------------------------------------
7""" Make javascript code blocks also include a live link to codepen.io for instant experiementation.
8
9This directive takes a title to use for the codepen example:
10
11.. code-block:: rest
12
13    .. bokehjs-content::
14        :title: Some Code
15
16        alert('this is called in the codepen');
17
18This directive is identical to the standard ``code-block`` directive
19that Sphinx supplies, with the addition of one new option:
20
21title : string
22    A title for the codepen.
23js_file : string
24    location of javascript source file
25include_html: string
26    if present, this code block will be emitted as a complete HTML template with
27    js inside a script block
28disable_codepen: string
29    if present, this code block will not have a 'try on codepen' button.  Currently
30    necessary when 'include_html' is turned on.
31
32Examples
33--------
34
35The inline example code above produces the following output:
36
37.. bokehjs-content::
38    :title: Some Code
39
40    alert('this is called in the codepen');
41
42"""
43
44# -----------------------------------------------------------------------------
45# Boilerplate
46# -----------------------------------------------------------------------------
47import logging  # isort:skip
48
49log = logging.getLogger(__name__)
50
51# -----------------------------------------------------------------------------
52# Imports
53# -----------------------------------------------------------------------------
54
55# Standard library imports
56from os.path import basename, join
57
58# External imports
59from docutils import nodes
60from docutils.parsers.rst.directives import unchanged
61from sphinx.directives.code import CodeBlock, container_wrapper, dedent_lines
62from sphinx.errors import SphinxError
63from sphinx.locale import __
64from sphinx.util import logging, parselinenos
65from sphinx.util.nodes import set_source_info
66
67# Bokeh imports
68from .templates import BJS_CODEPEN_INIT, BJS_EPILOGUE, BJS_HTML, BJS_PROLOGUE
69from .util import get_sphinx_resources
70
71if False:
72    # For type annotation
73    # from directives.code.CodeBlock.run
74    from typing import Any, Dict, List, Tuple  # NOQA
75    from sphinx.application import Sphinx  # NOQA
76    from sphinx.config import Config  # NOQA
77
78# -----------------------------------------------------------------------------
79# Globals and constants
80# -----------------------------------------------------------------------------
81
82__all__ = (
83    "bokehjs_content",
84    "BokehJSContent",
85    "html_depart_bokehjs_content",
86    "html_visit_bokehjs_content",
87    "setup",
88)
89
90# -----------------------------------------------------------------------------
91# General API
92# -----------------------------------------------------------------------------
93
94# -----------------------------------------------------------------------------
95# Dev API
96# -----------------------------------------------------------------------------
97
98
99class bokehjs_content(nodes.General, nodes.Element):
100    pass
101
102
103class BokehJSContent(CodeBlock):
104
105    has_content = True
106    optional_arguments = 1
107    required_arguments = 0
108
109    option_spec = CodeBlock.option_spec
110    option_spec.update(title=unchanged)
111    option_spec.update(js_file=unchanged)
112    option_spec.update(include_html=unchanged)
113    option_spec.update(disable_codepen=unchanged)
114
115    def get_codeblock_node(self, code, language):
116        """this is copied from sphinx.directives.code.CodeBlock.run
117
118        it has been changed to accept code and language as an arguments instead
119        of reading from self
120
121        """
122
123        document = self.state.document
124        location = self.state_machine.get_source_and_line(self.lineno)
125
126        linespec = self.options.get("emphasize-lines")
127        if linespec:
128            try:
129                nlines = len(code.split("\n"))
130                hl_lines = parselinenos(linespec, nlines)
131                if any(i >= nlines for i in hl_lines):
132                    emph_lines = self.options["emphasize-lines"]
133                    log.warning(__(f"line number spec is out of range(1-{nlines}): {emph_lines!r}"), location=location)
134
135                hl_lines = [x + 1 for x in hl_lines if x < nlines]
136            except ValueError as err:
137                return [document.reporter.warning(str(err), line=self.lineno)]
138        else:
139            hl_lines = None
140
141        if "dedent" in self.options:
142            location = self.state_machine.get_source_and_line(self.lineno)
143            lines = code.split("\n")
144            lines = dedent_lines(lines, self.options["dedent"], location=location)
145            code = "\n".join(lines)
146
147        literal = nodes.literal_block(code, code)
148        literal["language"] = language
149        literal["linenos"] = "linenos" in self.options or "lineno-start" in self.options
150        literal["classes"] += self.options.get("class", [])
151        extra_args = literal["highlight_args"] = {}
152        if hl_lines is not None:
153            extra_args["hl_lines"] = hl_lines
154        if "lineno-start" in self.options:
155            extra_args["linenostart"] = self.options["lineno-start"]
156        set_source_info(self, literal)
157
158        caption = self.options.get("caption")
159        if caption:
160            try:
161                literal = container_wrapper(self, literal, caption)
162            except ValueError as exc:
163                return [document.reporter.warning(str(exc), line=self.lineno)]
164
165        # literal will be note_implicit_target that is linked from caption and numref.
166        # when options['name'] is provided, it should be primary ID.
167        self.add_name(literal)
168
169        return [literal]
170
171    def get_js_source(self):
172        env = self.state.document.settings.env
173        js_file = self.options.get("js_file", False)
174        # js_file *or* js code content, but not both
175        if js_file and self.content:
176            raise SphinxError("bokehjs-content:: directive can't have both js_file and content")
177
178        if js_file:
179            log.debug(f"[bokehjs-content] handling external example in {env.docname!r}: {js_file}")
180            path = js_file
181            if not js_file.startswith("/"):
182                path = join(env.app.srcdir, path)
183            js_source = open(path).read()
184        else:
185            log.debug(f"[bokehjs-content] handling inline example in {env.docname!r}")
186            js_source = "\n".join(self.content)
187
188        return js_source
189
190    def get_code_language(self):
191        """
192        This is largely copied from bokeh.sphinxext.bokeh_plot.run
193        """
194        js_source = self.get_js_source()
195        if self.options.get("include_html", False):
196            resources = get_sphinx_resources(include_bokehjs_api=True)
197            html_source = BJS_HTML.render(css_files=resources.css_files, js_files=resources.js_files, hashes=resources.hashes, bjs_script=js_source)
198            return [html_source, "html"]
199        else:
200            return [js_source, "javascript"]
201
202    def run(self):
203        env = self.state.document.settings.env
204
205        rst_source = self.state_machine.node.document["source"]
206        rst_filename = basename(rst_source)
207
208        serial_no = env.new_serialno("ccb")
209        target_id = f"{rst_filename}.ccb-{serial_no}"
210        target_id = target_id.replace(".", "-")
211        target_node = nodes.target("", "", ids=[target_id])
212
213        node = bokehjs_content()
214        node["target_id"] = target_id
215        node["title"] = self.options.get("title", "bokehjs example")
216        node["include_bjs_header"] = False
217        node["disable_codepen"] = self.options.get("disable_codepen", False)
218        node["js_source"] = self.get_js_source()
219
220        source_doc = self.state_machine.node.document
221        if not hasattr(source_doc, "bjs_seen"):
222            # we only want to inject the CODEPEN_INIT on one
223            # bokehjs-content block per page, here we check to see if
224            # bjs_seen exists, if not set it to true, and set
225            # node['include_bjs_header'] to true.  This way the
226            # CODEPEN_INIT is only injected once per document (html
227            # page)
228            source_doc.bjs_seen = True
229            node["include_bjs_header"] = True
230        code_content, language = self.get_code_language()
231        cb = self.get_codeblock_node(code_content, language)
232        node.setup_child(cb[0])
233        node.children.append(cb[0])
234        return [target_node, node]
235
236
237def html_visit_bokehjs_content(self, node):
238    if node["include_bjs_header"]:
239        # we only want to inject the CODEPEN_INIT on one
240        # bokehjs-content block per page
241        resources = get_sphinx_resources(include_bokehjs_api=True)
242        self.body.append(BJS_CODEPEN_INIT.render(css_files=resources.css_files, js_files=resources.js_files))
243
244    self.body.append(
245        BJS_PROLOGUE.render(
246            id=node["target_id"],
247            title=node["title"],
248        )
249    )
250
251
252def html_depart_bokehjs_content(self, node):
253    self.body.append(BJS_EPILOGUE.render(title=node["title"], enable_codepen=not node["disable_codepen"], js_source=node["js_source"]))
254
255
256def setup(app):
257    """ Required Sphinx extension setup function. """
258    app.add_node(bokehjs_content, html=(html_visit_bokehjs_content, html_depart_bokehjs_content))
259    app.add_directive("bokehjs-content", BokehJSContent)
260
261
262# -----------------------------------------------------------------------------
263# Private API
264# -----------------------------------------------------------------------------
265
266# -----------------------------------------------------------------------------
267# Code
268# -----------------------------------------------------------------------------
269