pax_global_header00006660000000000000000000000064150416764470014530gustar00rootroot0000000000000052 comment=6f20fe80e1e42c2e336558a7b178536010191533 fralau-mkdocs-test-6f20fe8/000077500000000000000000000000001504167644700156575ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/.gitignore000066400000000000000000000007251504167644700176530ustar00rootroot00000000000000# Temporary files *~ .py~ .python-version #Byte-compiled led / optimized / DLL files __pycache__/ *.py[cod] *$py.class # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # MkDocs site/ # Mkdocs-Test, Mkdocs-Macros and others __*/ # Other files (generated by mkdocs-macros or others) cache* # JetBrains PyCharm and other IntelliJ based IDEs .idea/ fralau-mkdocs-test-6f20fe8/LICENSE000066400000000000000000000020651504167644700166670ustar00rootroot00000000000000MIT License Copyright (c) 2024 Laurent Franceschetti Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.fralau-mkdocs-test-6f20fe8/README.md000066400000000000000000000136741504167644700171510ustar00rootroot00000000000000
![MkDocs-Test](logo.png) # A testing framework (plugin + test fixture)
for MkDocs projects [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) ![Language](https://img.shields.io/github/languages/top/fralau/mkdocs-test) ![Github](https://img.shields.io/github/v/tag/fralau/mkdocs-test?label=github%20tag) ![PyPI](https://img.shields.io/pypi/v/mkdocs-test) ![Downloads](https://img.shields.io/pypi/dm/mkdocs-test) [View the documentation](https://mkdocs-test-plugin.readthedocs.io/en/latest/) on Read the Docs
- [A testing framework (plugin + test fixture)for MkDocs projects](#a-testing-framework-plugin--test-fixturefor-mkdocs-projects) - [Description](#description) - [What problem does it solve?](#what-problem-does-it-solve) - [MkDocs-Test](#mkdocs-test) - [Usage](#usage) - [Installation](#installation) - [From pypi](#from-pypi) - [Locally (Github)](#locally-github) - [Installing the plugin](#installing-the-plugin) - [Performing basic tests](#performing-basic-tests) - [Tests on a page](#tests-on-a-page) - [Testing the HTML](#testing-the-html) - [Performing advanced tests](#performing-advanced-tests) - [Reading the configuration file](#reading-the-configuration-file) - [Accessing page metadata](#accessing-page-metadata) - [Reading the log](#reading-the-log) - [License](#license) ## Description ### What problem does it solve? Traditionally, the quickest way for maintainers of an existing [MkDocs](https://www.mkdocs.org/) website project (or developers of an [MkDocs plugin](https://www.mkdocs.org/dev-guide/plugins/)) to check whether an MkDocs project builds correctly, is to run `mkdocs build` (possibly with the `--strict` option). It leaves the following issues open: - This is a binary proposition: it worked or it didn't. - It doesn't perform integrity tests on the pages; if something started to go wrong, the issue might emerge only later. - If something went already wrong, it doesn't necessarily say where, or why. ### MkDocs-Test The purpose of Mkdocs-Test is to facilitate the comparison of source pages (Markdown files) and destination pages (HTML) in an MkDocs project. MkDocs-Test is a framework composed of two parts: - an MkDocs plugin (`test`): it creates a `__test__` directory, which contains the data necessary to map the pages of the website. - a framework for conducting the test. The `DocProject` class groups together all the information necessary for the tests on a specific MkDocs project. > 📝 **Technical Note**
The plugin exports the `nav` object, > in the form of a dictionary of Page objects. ## Usage ### Installation #### From pypi ```sh pip install mkdocs-test ``` #### Locally (Github) ```sh pip install . ``` Or, to install the test dependencies (for testing _this_ package, not MkDocs projects): ```sh pip install .[test] ``` ### Installing the plugin > ⚠️ **The plugin is a pre-requisite**
The framework will not work > without the plugin (it exports the pages map into the > `__test__` directory). Declare the `test` plugin in your config file (typically `mkdocs.yml`): ```yaml plugins: - search - ... - test ``` ### Performing basic tests The choice of testing tool is up to you (the examples in this package were tested with [pytest](https://docs.pytest.org/en/stable/)). ```python from mkdocs_test import DocProject project = DocProject() # declare the project # (by default, the directory where the program resides) project.build(strict=False, verbose=False) # build the project; these are the default values for arguments assert project.success # return code of the build is zero (success) ? print(project.build_result.returncode) # return code from the build # perform automatic integrity checks (do pages exist?) project.self_check() ``` ### Tests on a page Each page of the MkDocs project can be tested separately ```python # find the page by relative pathname: page = project.pages['index.md'] # find the page by name, filename or relative pathname: page = project.get_page('index') # easy, and naïve search on the target html page assert "hello world" in page.html # find the words "second page", under the header that contains "subtitle" # at level 2; arguments header and header_level are optional # the method returns the text so found (if found) # the search is case insensitive assert page.find_text_text('second page', header="subtitle", header_level=2) ``` > ⚠️ **Two markdown versions**
`page.markdown` > contains the markdown after possible transformations > by plugins; whereas `page.source.markdown` contains the exact > markdown in the file. > > If you wish to have the complete source file (including the frontmatter), > use `page.source.text`. ### Testing the HTML You can directly access the `.find()` and `.find_all()` methods offered by [BeautifulSoup](https://www.crummy.com/software/BeautifulSoup/bs4/doc/#find-all). ```python page = project.get_page('foo') headers = page.find_all('h2') # get all headers of level 2 for header in headers: print(header.string) script = page.find('script', type="module") assert 'import foo' in script.string ``` ## Performing advanced tests ### Reading the configuration file ```python print(project.config.site_name) ``` ### Accessing page metadata ```python page = project.get_page('index') assert page.meta.foo = 'hello' # key-value pair in the page's frontmatter ``` ### Reading the log ```python # search in the trace (naïve) assert "Cleaning site" in project.trace # get all WARNING log entries entries = myproject.find_entries(severity='WARNING') # get the entry from source 'test', containing 'debug file' (case-insensitive) entry = project.find_entry('debug file', source='test') assert entry, "Entry not found" ``` ## License MIT Licensefralau-mkdocs-test-6f20fe8/cleanup.sh000077500000000000000000000007301504167644700176450ustar00rootroot00000000000000#!/bin/bash # This is necessary to start from a clean version for test # Find and delete all __pycache__ directories find . -type d -name "__pycache__" -exec rm -rf {} + # Find and delete all directories starting with __ find . -type d -name "__*" -exec rm -rf {} + # Find and delete all .egg-info directories find . -type d -name "*.egg-info" -exec rm -rf {} + # Find and delete all .egg files find . -type f -name "*.egg" -exec rm -f {} + echo "Cleanup complete!" fralau-mkdocs-test-6f20fe8/logo.png000066400000000000000000000263431504167644700173350ustar00rootroot00000000000000PNG  IHDRAbo pHYs+iTXtXML:com.adobe.xmp Mkdocs-Test - 1 2024-10-08 32b1917c-8997-4549-8456-cfb6169cf6c3 525265914179580 2 Fralau Canva (Renderer) O1'IDATxwxTe?LIB$Ti(,M)*`k}AW]ۺ.XTVP EzIH2df13!ɔ$0 a\W. '{TUU@P|' AKC@ u(A8 @P!@ A@ u(A8 @P!@ A{]F'$'E*AvK QEUT0U$#齩 EQtZjՓ$ UHV@ H:okb)@j#TUr"~>ƪTX4ȲXLfKɄXPmM&{g%IB[;ؽ7Ѫm[뀦m>o(1$%%w^e<<#@ h~nh75'˙`,CݚEyhTU seلZRT-IU UfU=ZQUus'BQ吙bBd_?;vO8-{8  FUUSz1Oǃcdz % N_^h֩rCUEE%<Ћú1g$YUDDes^xm۶ĉzիWSZZ~;ڵkwcxsĉzɲ7DEEѭ[7!-L&o߾ݻjyq %ʙHrJx wPt%{ ;w ,<_dYTeYM9G.Qx ;Tra$6ŘXO8ABB:zlFFӧOz~h1f4F!..#F0i$ $Lוg{_ (h6<+{Va,%`7Bx/3ەhߥ3AȲmЛoCeE%9u^۸{xkM jY,;XeTUUeNׯWSX,8q'N+„ H n8***ή,I;v3xX&eMwX01%!,4Yxx>2mڂRVQKÜ`ݺuWn͚5ɍCRR&Mbܸqdee]btܹO>{JxHUTUAMÒXY,*<$S1@NpLmEX1_TQ-fsXG5>Alݺب6999ڵfti&ϩST*+V3⨪PYrRUyW3,eTeV@ѶUG9 mH-biuƩNmDUPRPrlN;bv٨6ׯl67ьn,.\ȑ#9@P//s-9^I^0/Td) m?>ł)0ń0  D5ա&u:-a|5MNm۶LY~=7nT[˗/3evڅ^oiҷo_Μ9NBB`>>ӣGbcc]#''D)--E$$66;6ѨD.\@QQBBB&&&!Cn Ym2339v/_ NGxx8ǣ鮺oEQHNN&%%l"00mңGڴiӠ***ꫯ\ O{x[ULnzyW&#I(eTe_@ wIb!rB,$Tg40]X[y8z6m@K7A/]Ç۷o흭zE\\\cjj*&33ӡLeVXŊg),3r&,ee{ m[XXO<ıロs9-_Ÿ&?44y,o?VQf͚œO>IYYYƮd޼yL>Ƌ/fر\|Qcմ\w/aÆջg ԨTKcÆ :g6l7`ƌnMO=W5?oooڷo_dٲencFf.U&*l>r ]p8C}MkmHIIeX,\w:K*pOC^ɬ%1\`FΧP2ű] M6QUUUZ\\zjq4 ?9sxכ|>}ݻw}K,[_z5?x֮]SO=u)SWoQ}6m\Sɑ#G6mZCj_Gx篺o?O[uxdQUU c((d1q2G* )\Gpsٺ'x8 r>,Vǘu3Ъҳa 1n4zoYF[ R%L)*9^,UW(*,QuB=?u];\6lSO=իM4 d21m4a''OnSѵ-ʪHJJns(_?۾ˆ >nBϜ9s ^}h4233>裵>/NǴi۷/z\ٽ{w-wBBǝs#<>뮻HHHK.sN> ocDGG׺vZу(HOO_СCaU̘1Vmbʝ˲ҩ'$$ug8+(..eQP,TE%¹B:m2AE?ymMUU0UR~79PZ(3̗o*Xo}8X8N2u?a"(J*++LJ#G:dY~Kq,..t>y犊 γnte>|8+WtT;G4 /|[y0`@}?2s=ǸqdѢE9mëʬYSmܸUV!I~{v8W^^pnXصk+V`ڵ̞=iΪUHJJrZO;z1>|8>(C ŋL&.\G}T+HHHw>++5kְl2'3ٳYtCWevxb>dcnL2#'rH7k$).\XUfFUAʱ$ $hgm ZTl6_ASc3?~Cٞ={\>P;S߷fXų>2>o߾]ooo_rQ:K)w)!0ݺudX֬YØ1cKMMef3˗/w9+Wszj4z͋/ȉ'ؾ}* +h4 :˗Yk?F?vz=..;Xtԉ?i!!w6m??;vE'hxx esQ챎XŅb_.|F1iI\Bb.a./\EB +[(f^ֶ3d痣qf`1[Zb$G1cVZf1@L^䵠-Z>::M6I&my.h4,Xe[g@t;zZnΐ$ɩGnhh6O>]trrr_=NWvƌd[RRjQ^l6RU^Ŷ/JHNGl TyQl2sH610zQza GEB F,$Ij}Q^ki gРA\KKKMٍ$IbΝ̝;im? hjܙ˝"㴮Ncܸqnٳ'={tZ?;\۷o˾~ac#$$e|EEf">>e˖y~=ٷoK~m+IzrZvZo߾ݻР3I7KI*9UU4?_t-]ۇ9d EFefRKTvɾCgibp` :I!ʋyv],^XK6>|j6Va Loz=~H-Pkظq_jo۶Zo[lqp֭7|sgOWds-44[ҩS'X96uVAh'::zǻgddPPPPk.C}x):uΝ̙3ӟD.]6,ߧOFݨ9_+~s]Dߎ;PN<`]l˖-cȐ!h0[MI+"ZѾTEU)4*$=C*/aթKk!++&њ17xkE$҃sU+V|*jLJаP$cMƎ۷׊tf8qby0e<˷C֭ӧsNvIΝyxGܚw-U5ȶm5̛*UUU:{]($ZY"[ÝmK@Fv l8.3qI<ѳ cn #:HVB_zI$IUD8~Ȏ+111NkV ti5Mرc.͐`=M :tPbqUރV=Ö-[:th[,VZE|| Vrp"EEҵJ4ތ;͛ͯ[nux8tܹAyXNz<\i4k]e٥(5T,ի[jS/jZ?vFuܒqѦ)x9~8'On١C4hM<;AS5nhъ"l)M9Kx02с:t!9ᢘ 3<;hiI)cUBB'{u.7nC`zVVgnRm(Gcɒ%>w}GIIӲnag?ء~CW%کtRZZ걤ٳgC石xb{uy=z4Æ 94@׮]Yf III,ZUV2̙3}>=GT7q&f^ ߃`gCVzt2Ff~pxOXjKЪu+tE. _~i!6or-NҤZ~g&CI, org1$Ww,J.]XCf͚EBB۶mv,&-%QQQ 6/ 'i*ܝ";dcdz|rz->#,YB~~ [om9 Z,5Fra?ΪD)UX3儆0wnap-Ӽk'N,)6nGu>PXXȴi<`^|EuOtĈN[,c?~e( ˿/cyw^EEEnCaZ:>c\Yk4َ+OjWߡTx*/w B${yyXIN`6vWvq( %{j~y7H$~4A>|SC9\k.jݷީu5tl6uV ܹs]h]Ռ;^o2gbq̤|wt߼ysj5Izq C>+7RAdd$tZj*~F{999i\3F*ǰ+璒IMGQ$dY&&.F)SUQx> Õ$U`iajF^iebȺf(BK|l~\v=cG=5___^o(  3rDղrJi-Zƍ ++/3f"##=z[S$I,]T֭6mӲ˗/3tPo^+IéS8q"[nuڮm۶L0zxx95y衇xk3ʙ3gO6lõѣO搵(Kǐm6{.k &_] 6mԩo6:ubܹ޽!jM=JQs|WfH=SUdVX᲍sxUQBB1vRϦofٝO |c.^HgȡDk:ܰ D)J_b(.G=K.QU2N$ƺI>C̜9#G86kIڼp_uk*#F ((JJJ܊XWDfܹ.F^~e׿ҪU+Z- :PQn֭[exnVwN֭$K.~z8ഏH$&Mw%xW_}aÆѡCdY N}:?s?v*ΈbҥL6ͥym؆ϥXM<=FUU]s(?~Ǐ7,<W=B$VXA]fnJII!%%ccw߹|q=CLLӲ8Lc08zUUpm4ɒCvґG} Ÿn~z逸~VdAˠI7$[?aVj;sN5ZFV|,tډُ%0~jaWvL54hKUEQ1cUn.2~~~DDDлwoƍNj/͛嫯rFӧ?<ookn=x 3f $$m}V˰aoOkmۖ;v0{l5ԩO='Od޼y'|W͹sh4 ]vٳAGh􏔔))) F?z4OKEUUΞ=˙3gΦI%,,:Өբh$99/RPP`@Q C~- $''STT,Ӯ]j+A<ՁvÞ_~%+#Ғ2'4 list[LogEntry]: """ Parse the log entries, e.g.: DEBUG - Running 1 `page_markdown` events INFO - [macros] - Rendering source page: index.md DEBUG - [macros] - Page title: Home WARNING - [macros] - ERROR # _Macro Rendering Error_ _File_: `second.md` _UndefinedError_: 'foo' is undefined ``` Traceback (most recent call last): File "snip/site-packages/mkdocs_macros/plugin.py", line 665, in render DEBUG - Copying static assets. RULES: 1. Every entry starts with a severity code (Uppercase). 2. The message is then divided into: - source: between brackets, e.g. [macros] - title: the remnant of the first line, e.g. "Page title: Home" - payload: the rest of the message """ log_entries = [] current_entry = None mkdocs_log = mkdocs_log.strip() for line in mkdocs_log.split('\n'): match = re.match(r'^([A-Z]+)\s+-\s+(.*)', line) if match: if current_entry: log_entries.append(current_entry) severity = match.group(1) message = match.group(2) source_match = re.match(r'^\[(.*?)\]\s+-\s+(.*)', message) if source_match: source = source_match.group(1) title = source_match.group(2) else: source = '' title = message current_entry = {'severity': severity, 'source': source, 'title': title, 'payload': []} elif current_entry: # current_entry['payload'] += '\n' + line current_entry['payload'].append(line) if current_entry: log_entries.append(current_entry) # Transform the payloads into str: for entry in log_entries: entry['payload'] = '\n'.join(entry['payload']).strip() return [SuperDict(item) for item in log_entries] # --------------------------- # An Mkdocs Documentation project # --------------------------- class MkDocsPage(SuperDict): "A markdown page from MkDocs, with all its information (source, target)" MANDATORY_ATTRS = ['markdown', 'content', 'meta', 'file'] def __init__(self, page:dict): # Call the superclass's __init__ method super().__init__(page) for field in self.MANDATORY_ATTRS: if field not in self: raise AttributeError(f"Missing attribute '{field}'") @property def h1(self): "First h1 in the markdown" return get_first_h1(self.markdown) @property def plain_text(self): """ The content, as plain text """ try: return self._plain_text except AttributeError: soup = BeautifulSoup(self.content, "html.parser") self._plain_text = soup.get_text() return self._plain_text @property def html(self): """ The final HTML code that will be displayed, complete with javascript, etc. (the end product). """ try: return self._html except AttributeError: try: with open(self.file.abs_dest_path, 'r') as f: s = f.read() self._html = s return self._html except AttributeError as e: # to make sure we don't go into a weird recovery # with SuperDict, in case of AttributeError (get_attr) raise Exception(e) @property def source(self) -> SuperDict: """ The source information, drawn from the source file (it contains the original markdown, before any rendering). Attributes: text: the source text (the full page, as actually typed) markdown: the markdown part of the source text frontmatter: the YAML frontmatter of the page (as a string) meta: the parsed YAML front matter (as a dictionary) """ try: return self._source except AttributeError: try: # get the source file and decompose it src_filename = self.file.abs_src_path assert os.path.isfile(src_filename), f"'{src_filename}' does not exist" with open(src_filename, 'r') as f: source_text = f.read() markdown, frontmatter, meta = get_frontmatter(source_text) source = { 'text': source_text, 'markdown': markdown, 'frontmatter': frontmatter, 'meta': meta } self._source = SuperDict(source) return self._source except AttributeError as e: # to make sure we don't go into a weird recovery # with SuperDict, in case of AttributeError (get_attr) raise Exception(e) # ---------------------------------- # Smart functions # ---------------------------------- def find_text(self, pattern: str, header: str = None, header_level: int = None) -> str | None: """ Find a text or regex pattern in the html page (case-insensitive). Arguments: pattern: the text or regex header (text or regex): if specified, it finds it first, and then looks for the text between that header and the next one (any level). header_level: you can speciy it, if there is a risk of ambiguity. Returns: The line where the pattern was found, or None """ # it operates on the html return find_in_html(self.html, pattern=pattern, header=header, header_level=header_level) @property def soup(self) -> BeautifulSoup: """ Parsed content of the HTML page (as published). Returns: Soup object from BeautifulSoup """ try: return self._soup except AttributeError: self._soup = BeautifulSoup(self.html, 'html.parser') return self._soup def find_all(self, tag: str, *args, **kwargs) -> list[HTMLTag]: """ Extract tags from the HTML source and return them with their attributes and content. It wraps the soup.find_all() function of BeautifulSoup. For *args and **kwargs see [documentation](https://www.crummy.com/software/BeautifulSoup/bs4/doc/#find-all) Arguments: tag: the string argument of soup.find_all(), i.e. the tag Returns: Each tag returned in the list contains in particular `attrs` (a dictionary of the attributes) and `string` (the text within the tag, but None if there are nested tags). Note: For various ways of formulating the query: see [doc](https://www.crummy.com/software/BeautifulSoup/bs4/doc/#kinds-of-filters) """ tags = self.soup.find_all(tag, *args, **kwargs) return tags def find(self, tag: str, *args, **kwargs) -> HTMLTag|None: """ Extracts the first tag from the HTML source. It wraps the soup.find() function of BeautifulSoup. See: [doc](https://www.crummy.com/software/BeautifulSoup/bs4/doc/#find). """ return self.soup.find(tag, *args, **kwargs) def find_header(self, pattern: str, header_level:int=None) -> str | None: """ Finds a header in the Returns: The first header (h1, h2, h3...) that matches a pattern; otherwise None """ if header_level is None: criterion = re.compile(r'h[1-6]') else: criterion = f'h{header_level}' headers = self.soup.find_all(criterion, string=re.compile(pattern)) r = [header.text for header in headers] if len(r): return r[0] # ---------------------------------- # Predicate functions # ---------------------------------- def is_src_file(self) -> bool: """ **Predicate**: does the source (Markdown) file exist? """ return os.path.isfile(self.file.abs_src_path) def is_dest_file(self) -> bool: """ **Predicate**: does the destination file (HTML) exist? """ return os.path.isfile(self.file.abs_dest_path) def is_markdown_rendered(self) -> bool: """ **Predicate**: "Rendered" means that the raw Markdown is different from the source markdown; more accurately, that the source markdown is not contained in the target markdown. Please do NOT confuse this with the rendering of Markdown into HTML (with templates containing navigation, header and footer, etc.). Hence "not rendered" is a "nothing happened". It covers these cases: 1. No rendering of the markdown has taken place at all (no plugin, or plugin inactive, or not instructions within the page). 2. A header and/or footer were added to the Markdown code (in `on_pre_page_macros()` or in `on_post_page_macro()` in Mkdocs-Macros) but the Markdown itself was not modified. 3. An order to render was given, but there was actually NO rendering of the markdown, for some reason (error case). """ # make sure that the source is stripped, to be sure. return self.source.markdown.strip() not in self.markdown class DocProject(object): """ An object that describes the current MkDocs project being tested (any plugin). """ def __init__(self, project_dir:str='', path:str='', new:bool=False): """ Initialize the documentation project. Designed for pytest: if the path is not defined, it will take the path of the calling program. Arguments: project_dir: the project subdirectory name; default: empty, same level as path (the calling program) path: the path to the reference directory default: empty, i.e. directory of the calling program new: create the project dir if it does not exist. (without clearing it) If you use the make_config() method, the docs_dir MUST be 'docs'. Note: It does not perform any build. The `build()` method must be called explicitly. """ if not path: # get the caller's directory caller_frame = inspect.stack()[1] path = os.path.dirname(caller_frame.filename) path = os.path.abspath(path) project_dir = os.path.join(path, project_dir) self._project_dir = project_dir if not os.path.isdir(self._project_dir): if new: # create os.makedirs(self._project_dir, exist_ok=True) else: raise FileNotFoundError(f"Project directory '{self._project_dir}' does not exist." "If you want to force its creation set `new=True`") h1(f"{self.config.get('site_name', 'NO_NAME (yet)')} [{os.path.relpath(project_dir)}]") @property def project_dir(self) -> str: "The source directory of the MkDocs project (abs or relative path)" if not self._project_dir: raise FileNotFoundError("This project is deleted and no longer has a project directory.") return self._project_dir @property def docs_dir(self): """ The source directory of markdown files (full path). It attempts to get it from the config file (or returns the default 'docs' directory) """ full_dir = os.path.join(self.project_dir, self.config.get('docs_dir', DOCS_DEFAULT_DIRNAME)) if not os.path.isdir(full_dir): # raise FileNotFoundError(f"Source directory '{full_dir}' does not exist.") os.makedirs(full_dir, exist_ok=True) return full_dir @property def test_dir(self): "The test directory (full path)" return os.path.join(self.project_dir, TEST_DIRNAME) # ---------------------------------- # Create the project source # (this is optional) # ---------------------------------- def clear(self) -> int: """ Clear the docs source directory (and its subdirectories). Returns: Number of files removed. Raises: FileNotFoundError: If the documentation source directory does not exist. """ if not os.path.isdir(self.docs_dir): raise FileNotFoundError(f"Source directory '{self.docs_dir}' does not exist.") removed = 0 for root, dirs, files in os.walk(self.docs_dir, topdown=False): for name in files: os.remove(os.path.join(root, name)) removed += 1 for name in dirs: os.rmdir(os.path.join(root, name)) return removed def make_config(self, content: str='', filename: str = 'mkdocs.yml', **kwargs) -> str: """ Creates a config file (YAML, prettyfied) for the mkdocs project. It will make sure that the test plugin is defined. Arguments --------- content : str The YAML content as a string (to be parsed and merged). To facilitate the entry on a multiline string, the text is automatically aligned to the left margin (but keeping the indentations). filename : str The filename of the config file. **kwargs : Arguments interpreted as entries in the YAML file. These will precede the parsed content. Example ------- yaml = MyProject.make_config(site_name='My project', theme='mkdocs', plugins=['search']) Returns ------- str The final YAML configuration as written to disk. """ # Parse existing YAML content if any content = textwrap.dedent(content).strip() try: parsed_content = yaml.safe_load(content) if content.strip() else {} except yaml.YAMLError as e: raise ValueError(f"Invalid YAML content: {e}") # Merge with kwargs (kwargs take precedence) final_config = {**parsed_content, **kwargs} # Make sure that TEST_PLUGIN are present # (Otherwise it won't work) plugins = final_config.get('plugins') if plugins is None: final_config['plugins'] = ['search', TEST_PLUGIN] else: if TEST_PLUGIN not in plugins: final_config.append(TEST_PLUGIN) # Pretty-print YAML with indentation and ordering preserved pretty_yaml = yaml.dump(final_config, sort_keys=False, allow_unicode=True) # Write YAML config to file config_path = os.path.join(self.project_dir, filename) os.makedirs(os.path.dirname(config_path), exist_ok=True) with open(config_path, 'w', encoding='utf-8') as f: f.write(pretty_yaml) # store the name of the config file self._config_file = filename return pretty_yaml def add_source_page(self, pathname: str, content: str, meta: dict = {}) -> str: """ Add a source page to the project Arguments: ---------- pathname: The pathname of the page (relative to the docs directory). It can be a plain filename, or a relative pathname. Please do not forget to add the extension (typically, '.md') content: The page content, as a string (Markdown with HTML, etc.). To facilitate the entry on a multiline string, the text is automatically aligned to the left margin (but keeping the indentations). meta: The metadata that must go into the YAML page header. Returns: -------- The full page content with YAML front matter, as written to file. """ content = textwrap.dedent(content).strip() # Create full path full_path = os.path.join(self.docs_dir, pathname) # Ensure parent directory exists os.makedirs(os.path.dirname(full_path), exist_ok=True) # Generate YAML header if len(meta): yaml_header = yaml.dump(meta, sort_keys=False, allow_unicode=True).strip() full_page = f"---\n{yaml_header}\n---\n\n{content}" else: full_page = content # Write to file with open(full_path, "w", encoding="utf-8") as f: f.write(full_page) return full_page @property def source_pages(self): """ Get all documentation file names from self.docs_dir. Returns: -------- A list of relative file paths (to self.docs_dir) for all files. """ return [ str(path.relative_to(self.docs_dir)) for path in Path(self.docs_dir) ] def delete(self): """ Deletes the project dir and self. WARNING: Do NOT confuse with self.clear()! USE with caution, this deletes ALL files of the mkdocs project, including the directory. The object becomes inoperable. """ # to avoid failure, set the working directory one level up, # if it was set to the directory or one of its subdirs: if is_in_dir(os.getcwd(), self.project_dir): os.chdir(os.path.dirname(self.project_dir)) # delete the files shutil.rmtree(self.project_dir) # Remove link to project dir, object becomes inoperable. self._project_dir = None # ---------------------------------- # Reading the config file # ---------------------------------- @property def config_file(self) -> str: "The config file" try: return self._config_file except AttributeError: # List of possible mkdocs configuration filenames (default, if not found) CANDIDATES = ['mkdocs.yaml', 'mkdocs.yml'] for filename in os.listdir(self.project_dir): if filename in CANDIDATES: self._config_file = os.path.join(self.project_dir, filename) return self._config_file raise FileNotFoundError("No config file found (this is not an MkDocs directory).") @property def config(self) -> SuperDict: """ Get the configuration from the config file. All main items of the config are accessible with the dot notation. (config.site_name, config.theme, etc.) If no config file available, provisionally returns an empty SuperDict. """ try: return self._config except AttributeError: try: config_file = self.config_file with open(config_file, 'r', encoding='utf-8') as file: # self._config = SuperDict(yaml.safe_load(file)) self._config = SuperDict(yaml.load(file, Loader=MySafeLoader)) except FileNotFoundError: return SuperDict() return self._config def get_plugin(self, name:str) -> SuperDict: "Get a plugin config (from the Config file) by its plugin name" for el in self.config.plugins: if name in el: if isinstance(el, str): return SuperDict() elif isinstance(el, dict): plugin = el[name] return SuperDict(plugin) else: raise ValueError(f"Unexpected content of plugin {name}!") return SuperDict(self.config.plugins.get(name)) # ---------------------------------- # Build # ---------------------------------- def build(self, strict:bool=False, verbose:bool=False) -> subprocess.CompletedProcess: """ Build the documentation, to perform the tests (equivalent to `mkdocs build`). Running a build is necessary so that the tests can be performed. Arguments: strict: to make the build fail in case of warnings verbose: to generate the target_files directory Returns: (if desired) the low level result of the process (return code and stderr). This info is generally not needed, since, those values are stored, and parsed. """ os.chdir(self.project_dir) command = MKDOCS_BUILD.copy() assert '--strict' not in command if strict: command.append('--strict') if verbose: command.append('--verbose') print("BUILD COMMAND:", command) self._build_result = run_command(*command) return self.build_result # ---------------------------------- # Post-build properties # Will fail if called before build # ---------------------------------- @property def build_result(self) -> subprocess.CompletedProcess: """ Result of the build (low level) """ try: return self._build_result except AttributeError: raise AttributeError("No build result yet (not run)") @property def success(self) -> bool: "Was the execution of the build a success?" return self.build_result.returncode == 0 # ---------------------------------- # Get the Markdown pages # ---------------------------------- @property def page_map_file(self): "The page map file exported by the Test plugin" filename = os.path.join(self.test_dir, PAGE_MAP) if not os.path.isfile(filename): raise FileNotFoundError("The pagemap file was not found. " "Did you forget to declare the `test` plugin " "in the MkDocs config file?") return filename @property def pages(self) -> dict[MkDocsPage]: """ The dictionary containing the pages (Markdown + HTML + ...) produced by the build. """ try: return self._pages except AttributeError: # build the pages with open(self.page_map_file, 'r') as file: pages = json.load(file) self._pages = {key: MkDocsPage(value) for key, value in pages.items()} return self._pages def get_page(self, name:str) -> MkDocsPage | None: """ Find a name in the list of Markdown pages (filenames) Arguments: name: a page name (full or partial, with or without extension). """ # get all the filenames of pages: filenames = [filename for filename in self.pages.keys()] # get the filename we want, from that list: filename = find_page(name, filenames) # return the corresponding page: return self.pages.get(filename) # ---------------------------------- # Log # ---------------------------------- @property def trace(self) -> str: "Trace of the execution (the log) as text" return self.build_result.stderr @property def log(self) -> List[LogEntry]: """ The parsed trace (LogEntry objects) """ try: return self._log except AttributeError: self._log = parse_log(self.trace) # print("BUILT:", self.log) return self._log @property def log_severities(self) -> List[str]: """ List of severities (DEBUG, INFO, WARNING) found in the log """ try: return self._log_severities except AttributeError: self._log_severities = list({entry.get('severity', '#None') for entry in self.log}) return self._log_severities def find_entries(self, title:str='', source:str='', severity:str='') -> List[LogEntry]: """ Filter entries according to criteria of title and severity; all criteria are case-insensitive. Arguments: title: regex source: regex, for which entity issued it (macros, etc.) severity: one of the existing sevirities """ if not title and not severity and not source: return self.log severity = severity.upper() # if severity and severity not in self.log_severities: # raise ValueError(f"{severity} not in the list") filtered_entries = [] # Compile the title regex pattern once (if provided) title_pattern = re.compile(title, re.IGNORECASE) if title else None source_pattern = re.compile(source, re.IGNORECASE) if source else None for entry in self.log: # Check if the entry matches the title regex (if provided) if title_pattern: title_match = re.search(title_pattern, entry.get('title', '')) else: title_match = True # Check if the entry matches the source regex (if provided) if source_pattern: source_match = re.search(source_pattern, entry.get('source', '')) else: source_match = True # Check if the entry matches the severity (if provided) if severity: severity_match = (entry['severity'] == severity) # print("Decision:", severity_match) else: severity_match = True # If both conditions are met, add the entry to the filtered list if title_match and severity_match and source_match: filtered_entries.append(entry) assert isinstance(filtered_entries, list) return filtered_entries def find_entry(self, title:str='', source:str = '', severity:str='') -> SuperDict | None: """ Find the first entry according to criteria of title and severity Arguments: title: regex source: regex severity: the severity, e.g. DEBUG, INFO, WARNING """ found = self.find_entries(title, source=source, severity=severity) if len(found): return found[0] else: return None # ---------------------------------- # Self-check # ---------------------------------- def self_check(self): "Performs a number of post-build self-checks (integrity)" for page in self.pages.values(): name = page.file.name assert page.markdown, f"'{name}' is empty" assert page.is_src_file(), f"source (Markdown) of '{name}' is missing" assert page.is_dest_file(), f"destination (HTML) of '{name}' is missing"fralau-mkdocs-test-6f20fe8/mkdocs_test/common.py000066400000000000000000000246651504167644700220550ustar00rootroot00000000000000""" Fixtures utilities for the testing of Mkdocs-Macros (pytest) Part of the test package. Not all are used, but they are maintained here for future reference. (C) Laurent Franceschetti 2024 """ import os import re from io import StringIO import inspect import subprocess import yaml from typing import List import markdown import pandas as pd from bs4 import BeautifulSoup from super_collections import SuperDict # ------------------------------------------ # Initialization # ------------------------------------------ # the test plugin's name TEST_PLUGIN = 'test' # the directory where the export files must go TEST_DIRNAME = '__test__' "The default docs directory" DOCS_DEFAULT_DIRNAME = 'docs' "The mapping file (communication between plugin and test)" PAGE_MAP = 'page_map.json' # --------------------------- # Print functions # --------------------------- std_print = print from rich import print from rich.panel import Panel from rich.table import Table TITLE_COLOR = 'green' def h1(s:str, color:str=TITLE_COLOR): "Color print a 1st level title to the console" print() print(Panel(f"[{color} bold]{s}", style=color, width=80)) def h2(s:str, color:str=TITLE_COLOR): "Color print a 2nd level title to the consule" print() print(f"[green bold underline]{s}") def h3(s:str, color:str=TITLE_COLOR): "Color print a 2nd level title to the consule" print() print(f"[green underline]{s}") # --------------------------- # Low-level functions # --------------------------- def find_after(s:str, word:str, pattern:str): """ Find the the first occurence of a pattern after a word (Both word and pattern can be regex, and the matching is case insensitive.) """ word_pattern = re.compile(word, re.IGNORECASE) parts = word_pattern.split(s, maxsplit=1) # parts = s.split(word, 1) if len(parts) > 1: # Strip the remainder and search for the pattern remainder = parts[1].strip() match = re.search(pattern, remainder, flags=re.IGNORECASE) return match.group(0) if match else None else: return None def find_page(name:str, filenames:List) -> str: """ Find a name in list of filenames using a name (full or partial, with or without extension). """ for filename in filenames: # give priority to exact matches # print("Checking:", filename) if name == filename: return filename # try without extension stem, _ = os.path.splitext(filename) # print("Checking:", stem) if name == stem: return filename # try again without full path for filename in filenames: if filename.endswith(name): return filename stem, _ = os.path.splitext(filename) # print("Checking:", stem) if stem.endswith(name): return filename def list_markdown_files(directory:str): """ Makes a list of markdown files in a directory """ markdown_files = [] for root, dirs, files in os.walk(directory): for file in files: if file.endswith('.md') or file.endswith('.markdown'): relative_path = os.path.relpath(os.path.join(root, file), directory) markdown_files.append(relative_path) return markdown_files def markdown_to_html(markdown_text): """Convert markdown text to HTML.""" html = markdown.markdown(markdown_text, extensions=["tables"]) return html def style_dataframe(df:pd.DataFrame): """ Apply beautiful and colorful styling to any dataframe (patches the dataframe). """ def _rich_str(self): table = Table(show_header=True, header_style="bold magenta") # Add columns for col in self.columns: table.add_column(col, style="dim", width=12) # Add rows for row in self.itertuples(index=False): table.add_row(*map(str, row)) return table # reassign str to rich (to avoid messing up when rich.print is used) df.__rich__ = _rich_str.__get__(df) # -------------------------------------------- # Smart find/extraction functions (HTML) # -------------------------------------------- def extract_tables_from_html(html:str, formatter:callable=None): """ Extract tables from an HTML source and convert them into dataframes """ soup = BeautifulSoup(html, 'html.parser') tables = soup.find_all('table') dataframes = {} unnamed_table_count = 0 for table in tables: print("Found a table") # Find the nearest header header = table.find_previous(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']) if header: header_text = header.get_text() else: unnamed_table_count += 1 header_text = f"Unnamed Table {unnamed_table_count}" # Convert HTML table to DataFrame df = pd.read_html(StringIO(str(table)))[0] if formatter: formatter(df) # Add DataFrame to dictionary with header as key dataframes[header_text] = df return dataframes def find_in_html(html: str, pattern: str, header: str = None, header_level: int = None) -> str | None: """ Find a text or regex pattern in a HTML document (case-insensitive) Arguments --------- - html: the html string - pattern: the text or regex - header (text or regex): if specified, it finds it first, and then looks for the text between that header and the next one (any level). - header_level: you can speciy it, if there is a risk of ambiguity. Returns ------- The line where the pattern was found, or None """ if not isinstance(pattern, str): pattern = str(pattern) soup = BeautifulSoup(html, 'html.parser') # Compile regex patterns with case-insensitive flag pattern_regex = re.compile(pattern, re.IGNORECASE) if header: header_regex = re.compile(header, re.IGNORECASE) # Find all headers (h1 to h6) headers = soup.find_all(re.compile('^h[1-6]$', re.IGNORECASE)) for hdr in headers: if header_regex.search(hdr.text): # Check if header level is specified and matches if header_level and hdr.name != f'h{header_level}': continue # Extract text until the next header text = [] for sibling in hdr.find_next_siblings(): if sibling.name and re.match('^h[1-6]$', sibling.name, re.IGNORECASE): break text.append(sibling.get_text(separator='\n', strip=True)) full_text = '\n'.join(text) # Search for the pattern in the extracted text match = pattern_regex.search(full_text) if match: # Find the full line containing the match lines = full_text.split('\n') for line in lines: if pattern_regex.search(line): return line else: # Extract all text from the document full_text = soup.get_text(separator='\n', strip=True) # Search for the pattern in the full text match = pattern_regex.search(full_text) if match: # Find the full line containing the match lines = full_text.split('\n') for line in lines: if pattern_regex.search(line): return line return None # -------------------------------------------- # Smart find/extraction functions (Markdown) # -------------------------------------------- def get_frontmatter(text:str) -> tuple[str, dict]: """ Get the front matter from a markdown file. Returns ------- - markdown - frontmatter - metadata """ # Split the content to extract the YAML front matter parts = text.split('---',maxsplit=2) if len(parts) > 1: frontmatter = parts[1].strip() metadata = SuperDict(yaml.safe_load(frontmatter)) try: markdown = parts[2] except IndexError: markdown = '' return (markdown.strip(), frontmatter, metadata) else: return (text, '', {}) def get_first_h1(markdown_text: str): """ Get the first h1 in a markdown file, ignoring YAML frontmatter and comments. """ # Remove YAML frontmatter yaml_frontmatter_pattern = re.compile(r'^---\s*\n(.*?\n)?---\s*\n', re.DOTALL) markdown_text = yaml_frontmatter_pattern.sub('', markdown_text) # Regular expression to match both syntaxes for level 1 headers h1_pattern = re.compile(r'^(# .+|.+\n=+)', re.MULTILINE) match = h1_pattern.search(markdown_text) if match: header = match.group(0) # Remove formatting if header.startswith('#'): return header.lstrip('# ').strip() else: return header.split('\n')[0].strip() return None def get_tables(markdown_text:str) -> dict[pd.DataFrame]: """ Convert markdown text to HTML, extract tables, and convert them to dataframes. """ html = markdown_to_html(markdown_text) dataframes = extract_tables_from_html(html, formatter=style_dataframe) return dataframes # --------------------------- # OS Functions # --------------------------- def run_command(command, *args) -> subprocess.CompletedProcess: "Execute a command" full_command = [command] + list(args) return subprocess.run(full_command, capture_output=True, text=True) def get_caller_directory(): "Get the caller's directory name (to be called from a function)" # Get the current frame current_frame = inspect.currentframe() # Get the caller's frame caller_frame = inspect.getouterframes(current_frame, 2) # Get the file name of the caller caller_file = caller_frame[1].filename # Get the absolute path of the directory containing the caller file directory_abspath = os.path.abspath(os.path.dirname(caller_file)) return directory_abspath def is_in_dir(pathname: str, parent: str) -> bool: "Check if a pathname is in a parent directory" # Normalize and resolve both paths pathname = os.path.abspath(pathname) parent = os.path.abspath(parent) return os.path.commonpath([pathname, parent]) == parent fralau-mkdocs-test-6f20fe8/mkdocs_test/lorem.py000066400000000000000000000057131504167644700216740ustar00rootroot00000000000000""" Lorem ipsum generator """ import random import textwrap # Constant for identifying source call CALL_TAG = 'lorem_ipsum(' # Sentence templates and vocabulary pool SENTENCE_TEMPLATES = [ "Lorem ipsum dolor sit amet, {tail}.", "Sed do eiusmod tempor {action} ut labore et dolore magna aliqua.", "Ut enim ad minim veniam, {phrase}, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", "Duis aute irure dolor in reprehenderit in {setting} velit esse cillum dolore eu fugiat nulla pariatur.", "Excepteur sint occaecat cupidatat {effect}, sunt in culpa qui officia deserunt mollit anim id est laborum.", "{intro} consectetur adipiscing elit.", "Aenean euismod bibendum laoreet. {extra}" ] WORD_CHOICES = { "tail": ["consectetur adipiscing elit", "accumsan et malesuada fames", "commodo viverra maecenas accumsan"], "action": ["incididunt", "aliquip tempor", "commodo consequat", "reliquaverit noditer"], "phrase": ["quis minim veniam", "quis nisi", "velit esse"], "setting": ["voluptate", "laboris", "exercitation"], "effect": ["non proident", "culpa magna", "tempor incididunt", 'nolenter excpidierunt'], "intro": ["Maecenas", "Phasellus", "Integer", "Boticellus"], "extra": ["Donec vitae sapien ut libero", "Curabitur blandit tempus porttitor", "Nulla vitae elit libero"] } def generate_sentence(template): return template.format(**{ key: random.choice(WORD_CHOICES[key]) for key in WORD_CHOICES if f'{{{key}}}' in template }) import textwrap def lorem_ipsum(paragraphs: int = 1, indent: str = '', width: int = 60) -> str: """ Generates wrapped Lorem Ipsum pagraphs with: - Only the very first line unindented - `indent` (string of blank spaces) applied to all other lines - One blank line between paragraphs Arguments: paragraphs: the no of paragraphs required. indent: the indentation for the second line and the next ones. width: the width of a line Returns: Paragraphs of Lorem Ipsum text, optionally indented from the second line. """ all_lines = [] first_line_used = False for _ in range(paragraphs): # Generate raw paragraph text sentences = [generate_sentence(random.choice(SENTENCE_TEMPLATES)) for _ in range(random.randint(4, 7))] paragraph = ' '.join(sentences) # Wrap the paragraph text wrapped = textwrap.wrap(paragraph, width=width) # Apply indent logic for i, line in enumerate(wrapped): if not first_line_used: all_lines.append(line) first_line_used = True else: all_lines.append(f"{indent}{line}") # Add two blank lines *after* the current paragraph all_lines.append('') return '\n'.join(all_lines).rstrip() if __name__ == "__main__": # Your test or main execution logic here print(lorem_ipsum(2, ' ')) fralau-mkdocs-test-6f20fe8/mkdocs_test/plugin.py000066400000000000000000000072701504167644700220540ustar00rootroot00000000000000# -------------------------------------------- # Main part of the plugin # Defines the MacrosPlugin class # # Laurent Franceschetti (c) 2018 # MIT License # -------------------------------------------- import os import json import logging from bs4 import BeautifulSoup from mkdocs.config.defaults import MkDocsConfig from mkdocs.structure.files import Files from super_collections import SuperDict from mkdocs.plugins import BasePlugin from mkdocs.structure.pages import Page from mkdocs.structure.nav import Navigation try: from mkdocs.plugins import event_priority except ImportError: event_priority = lambda priority: lambda f: f # No-op fallback from .common import TEST_DIRNAME, DOCS_DEFAULT_DIRNAME, PAGE_MAP, get_frontmatter LOWEST_PRIORITY = -90 # ------------------------------------------ # Utilities # ------------------------------------------ log = logging.getLogger(f"mkdocs.plugins.test") def fmt(*args): "Format text for the log" items = ['[test] - '] + [str(arg) for arg in args] return ' '.join(items) def convert_object(object) -> SuperDict: "Convert an object to a dictionary" d = {key: value for key, value in object.__dict__.items() if not key.startswith('_') and isinstance(value, (str, int, float, dict))} return SuperDict(d) def check_dir(dest_file:str): "Check that the directories of a destination file exist" os.makedirs(os.path.dirname(dest_file), exist_ok=True) # ------------------------------------------ # Plugin # ------------------------------------------ class TestPlugin(BasePlugin): """ This plugin generates information necessary for testing MkDocs project """ # ---------------------------- # Directories # ---------------------------- @property def docs_dir(self) -> str: "The docs directory (relative to project dir)" return self.config.get('docs_dir', DOCS_DEFAULT_DIRNAME) @property def test_dir(self) -> str: "Return the test dir" return TEST_DIRNAME @property def nav(self): "Get the nav" try: return self._nav except AttributeError: raise AttributeError("Trying to access the nav attribute too early") @property def source_markdown(self) -> SuperDict: "The table raw (target) markdown (used to complement the page table)" try: return self._source_markdown except AttributeError: self._source_markdown = SuperDict() return self._source_markdown # ---------------------------- # Pages # ---------------------------- def get_page_map(self) -> SuperDict: """ Recursively build the mapping of pages from self.nav: all pages, created by on_nav(). """ pages = [] for page in self.nav.pages: d = convert_object(page) d.file = convert_object(page.file) pages.append(d) return SuperDict({page.file.src_uri: page for page in pages}) # ---------------------------- # Handling events # ---------------------------- @event_priority(LOWEST_PRIORITY) def on_nav(self, nav, config, files): "Set the nav" self._nav = nav @event_priority(LOWEST_PRIORITY) def on_post_build(self, config): """ The most important action: export all pages This method is called at the end of the build process """ mapping = self.get_page_map() out_file = os.path.join(self.test_dir, PAGE_MAP) log.info(fmt("Debug file:", out_file)) check_dir(out_file) with open(out_file, 'w') as f: json.dump(mapping, f, indent=4) fralau-mkdocs-test-6f20fe8/pyproject.toml000066400000000000000000000023171504167644700205760ustar00rootroot00000000000000[build-system] requires = ["setuptools>=61.0"] build-backend = "setuptools.build_meta" [project] name = "mkdocs-test" version = "0.5.6" description = "A test framework for MkDocs projects" authors = [{ name = "Laurent Franceschetti" }] license = { text = "LICENSE" } readme = "README.md" dependencies = [ "beautifulsoup4", "markdown", "mkdocs", "pandas", "pyyaml", "rich", "super-collections", ] classifiers = [ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", ] [project.urls] Source = "https://github.com/fralau/mkdocs-test" [project.optional-dependencies] # for self-testing (test/): test = ["pytest>=7.0", "toml-cli", "mkdocs-test"] # for the MkDocs documentation (webdoc/) doc = ["mkdocs-alabaster", "mkdocstrings[python]"] [tool.mkdocs] site_name = "MkDoc Test Documentation" [tool.mkdocs.plugins] test = {} [project.entry-points."mkdocs.plugins"] test = "mkdocs_test.plugin:TestPlugin" [tool.setuptools] packages = { find = { exclude = ["*.tests", "webdoc"] } } fralau-mkdocs-test-6f20fe8/readthedocs.yml000066400000000000000000000014671504167644700206770ustar00rootroot00000000000000# .readthedocs.yml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 # Set the OS, Python version and other tools you might need build: os: ubuntu-22.04 tools: python: "3.11" # You can also specify other tool versions: # nodejs: "20" # rust: "1.70" # golang: "1.20" # Build documentation in the docs/ directory with Sphinx # sphinx: # configuration: docs/conf.py # Python environment python: install: # these are needed, because they are not part of standard setup - requirements: webdoc/extra_requirements.txt # Build documentation with MkDocs mkdocs: configuration: webdoc/mkdocs.yml fail_on_warning: false # Optionally build your docs in additional formats such as PDF and ePub formats: allfralau-mkdocs-test-6f20fe8/test/000077500000000000000000000000001504167644700166365ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/advanced/000077500000000000000000000000001504167644700204035ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/advanced/__init__.py000066400000000000000000000001241504167644700225110ustar00rootroot00000000000000""" This __init__.py file is indispensable for pytest to recognize its packages. """fralau-mkdocs-test-6f20fe8/test/advanced/docs/000077500000000000000000000000001504167644700213335ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/advanced/docs/index.md000066400000000000000000000001461504167644700227650ustar00rootroot00000000000000# Main Page Hello world! This is to test a very simple case of an MkDocs project, with two pages. fralau-mkdocs-test-6f20fe8/test/advanced/docs/other/000077500000000000000000000000001504167644700224545ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/advanced/docs/other/third.md000066400000000000000000000000631504167644700241070ustar00rootroot00000000000000# This is a third file This is a file in a subdir.fralau-mkdocs-test-6f20fe8/test/advanced/docs/second.md000066400000000000000000000001301504167644700231220ustar00rootroot00000000000000--- foo: "Hello world" --- # Second page ## This is a subtitle This is a second page. fralau-mkdocs-test-6f20fe8/test/advanced/mkdocs.yml000066400000000000000000000002451504167644700224070ustar00rootroot00000000000000site_name: Simple MkDocs Website theme: mkdocs nav: - Home: index.md - Next page: second.md - Third page: other/third.md plugins: - search - test fralau-mkdocs-test-6f20fe8/test/advanced/test_advanced.py000066400000000000000000000032501504167644700235610ustar00rootroot00000000000000""" Testing the project (C) Laurent Franceschetti 2024 """ import pytest from mkdocs_test import DocProject from mkdocs_test.common import h1, h2, h3 def test_pages(): project = DocProject() project.build(strict=False) h1(f"Testing project: {project.config.site_name}") # did not fail return_code = project.build_result.returncode assert not return_code, "Failed when it should not" # ---------------- # Test log # ---------------- print(project.log) entry = project.find_entry(source='test') print("---") print("Confirming export:", entry.title) # ---------------- # First page # ---------------- pagename = 'index' h2(f"Testing: {pagename}") page = project.get_page(pagename) print("Plain text:", page.plain_text) # ---------------- # Second page # ---------------- # there is intentionally an error (`foo` does not exist) pagename = 'second' h2(f"Testing: {pagename}") page = project.get_page(pagename) assert page.meta.foo == "Hello world" assert "second page" in page.plain_text assert page.find_text('second page',header="subtitle", header_level=2) # ---------------- # Third page # check that it handles subdirs correctly # ---------------- page_path = 'other/third.md' page = project.pages[page_path] # it is found by its pathname assert "# This is a third file" in page.markdown def test_strict(): "This project must fail" project = DocProject() # it must not fail with the --strict option, project.build(strict=True) assert not project.build_result.returncode, "Failed when it should not" fralau-mkdocs-test-6f20fe8/test/alter_markdown/000077500000000000000000000000001504167644700216475ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/alter_markdown/__init__.py000066400000000000000000000001241504167644700237550ustar00rootroot00000000000000""" This __init__.py file is indispensable for pytest to recognize its packages. """fralau-mkdocs-test-6f20fe8/test/alter_markdown/docs/000077500000000000000000000000001504167644700225775ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/alter_markdown/docs/index.md000066400000000000000000000002051504167644700242250ustar00rootroot00000000000000# Main Page This is to show the alteration. ## Values Show the values of {x} and {y}. ## Message Here is the message: {message}fralau-mkdocs-test-6f20fe8/test/alter_markdown/docs/second.md000066400000000000000000000001361504167644700243740ustar00rootroot00000000000000# Second page ## This is a subtitle This is a second page that doesn't do anything special. fralau-mkdocs-test-6f20fe8/test/alter_markdown/hooks.py000066400000000000000000000007471504167644700233540ustar00rootroot00000000000000""" Hook script for altering the code """ MY_VARIABLES = {"x": 5, "y": 12, "message": 'hello world'} def on_page_markdown(markdown:str, *args, **kwargs) -> str | None: """ Process the markdown template, by interpolating the variables e.g. "{x} and {y}" -> "5 and 12" Note: ----- .format(), contrary to f-strings, does not allow inline expressions: the expression "{x + y}" won't work. """ raw_markdown = markdown.format(**MY_VARIABLES) return raw_markdown fralau-mkdocs-test-6f20fe8/test/alter_markdown/mkdocs.yml000066400000000000000000000003451504167644700236540ustar00rootroot00000000000000site_name: Alteration of source Markdown theme: readthedocs nav: - Home: index.md - Next page: second.md hooks: # Mkdocs hook for doing the alteration (instead of a plugin) - hooks.py plugins: - search - test fralau-mkdocs-test-6f20fe8/test/alter_markdown/test_site.py000066400000000000000000000033031504167644700242230ustar00rootroot00000000000000""" Testing the project (C) Laurent Franceschetti 2024 """ import pytest from mkdocs_test import DocProject from mkdocs_test.common import h1, h2, h3 from .hooks import MY_VARIABLES def test_pages(): project = DocProject() project.build(strict=True) h1(f"Testing project: {project.config.site_name}") # did not fail return_code = project.build_result.returncode assert not return_code, "Failed when it should not" # ---------------- # Test log # ---------------- print(project.log) entry = project.find_entry(source='test') print("---") print("Confirming export:", entry.title) # ---------------- # First page # ---------------- pagename = 'index' h2(f"Testing: {pagename}") page = project.get_page(pagename) print("Plain text:", page.plain_text) # it has been altered assert page.markdown.strip() != page.source.markdown.strip() assert page.is_markdown_rendered() # check that markdown is rendered # null test assert "foobar" not in page.markdown # brute-force testing assert "hello world" in page.markdown.lower() # check that the values of the variables have been properly rendered: assert page.find_text(MY_VARIABLES['x'], header="Values") assert page.find_text(MY_VARIABLES['y'], header="Values") assert page.find_text(MY_VARIABLES['message'], header="Message") # ---------------- # Second page # ---------------- pagename = 'second' h2(f"Testing: {pagename}") page = project.get_page(pagename) assert page # not altered assert page.markdown.strip() == page.source.markdown.strip() assert not page.is_markdown_rendered() fralau-mkdocs-test-6f20fe8/test/simple/000077500000000000000000000000001504167644700201275ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/simple/__init__.py000066400000000000000000000001241504167644700222350ustar00rootroot00000000000000""" This __init__.py file is indispensable for pytest to recognize its packages. """fralau-mkdocs-test-6f20fe8/test/simple/docs/000077500000000000000000000000001504167644700210575ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/test/simple/docs/index.md000066400000000000000000000001461504167644700225110ustar00rootroot00000000000000# Main Page Hello world! This is to test a very simple case of an MkDocs project, with two pages. fralau-mkdocs-test-6f20fe8/test/simple/docs/second.md000066400000000000000000000002141504167644700226510ustar00rootroot00000000000000--- foo: "Hello world" --- # Second page ## This is a subtitle This is a second page. ## Second header of level two This is more text. fralau-mkdocs-test-6f20fe8/test/simple/mkdocs.yml000066400000000000000000000002041504167644700221260ustar00rootroot00000000000000site_name: Simple MkDocs Website theme: mkdocs nav: - Home: index.md - Next page: second.md plugins: - search - test fralau-mkdocs-test-6f20fe8/test/simple/test_site.py000066400000000000000000000035721504167644700225130ustar00rootroot00000000000000""" Testing the project (C) Laurent Franceschetti 2024 """ import pytest from mkdocs_test import DocProject from mkdocs_test.common import h1, h2, h3 def test_pages(): project = DocProject() project.build(strict=False) h1(f"Testing project: {project.config.site_name}") # did not fail return_code = project.build_result.returncode assert not return_code, "Failed when it should not" # ---------------- # Test log # ---------------- print(project.log) entry = project.find_entry(source='test') print("---") print("Confirming export:", entry.title) # ---------------- # First page # ---------------- pagename = 'index' h2(f"Testing: {pagename}") page = project.get_page(pagename) print("Plain text:", page.plain_text) # ---------------- # Second page # ---------------- # there is intentionally an error (`foo` does not exist) pagename = 'second' h2(f"Testing: {pagename}") page = project.get_page(pagename) assert page.meta.foo == "Hello world" assert "second page" in page.plain_text assert page.find_text('second page',header="subtitle", header_level=2) # test find_header() method assert page.find_header('subtitle', 2) # by level assert not page.find_header('subtitle', 3) assert page.find_header('subtitle') # all levels # test find_all; all headers of level 2: headers = page.find_all('h2') assert len(headers) == 2 print("Headers found:", headers) assert "Second header" in headers[1].string # check that find also works: assert page.find('h2').string == headers[0].string def test_strict(): "This project must fail" project = DocProject() # it must not fail with the --strict option, project.build(strict=True) assert not project.build_result.returncode, "Failed when it should not" fralau-mkdocs-test-6f20fe8/test/test_ad_hoc.py000066400000000000000000000053051504167644700214670ustar00rootroot00000000000000""" Testing a simple project which is built ad hoc (programmatically) (C) Laurent Franceschetti 2025 """ import time import pytest from mkdocs_test import DocProject, lorem_ipsum from mkdocs_test.common import h1, h2, h3 def test_pages(): project = DocProject("ad_hoc", new=True) project.clear() time.sleep(2) # so that we can see the files disappear project.add_source_page("index.md",""" # Main Page Hello world! This is to test a very simple case of an MkDocs project, with two pages. """) project.add_source_page("second.md", f""" # Second page ## This is a subtitle This is a second page. ## Second header of level two This is more text. {lorem_ipsum(3, ' ')} Hello. """, meta={'foo':'Hello world'}) project.make_config(site_name="Simple ad-hoc test site", theme='mkdocs') project.build(strict=False) h1(f"Testing project: {project.config.site_name}") # did not fail return_code = project.build_result.returncode assert not return_code, "Failed when it should not" # ---------------- # Test log # ---------------- print(project.log) entry = project.find_entry(source='test') print("---") print("Confirming export:", entry.title) # ---------------- # First page # ---------------- pagename = 'index' h2(f"Testing: {pagename}") page = project.get_page(pagename) assert page, f"Page '{pagename}' does not exist in {project.pages}" print("Plain text\n:", page.plain_text) # ---------------- # Second page # ---------------- # there is intentionally an error (`foo` does not exist) pagename = 'second' h2(f"Testing: {pagename}") page = project.get_page(pagename) assert page, f"Page '{pagename}' does not exist in {project.pages}" assert page.meta.foo == "Hello world" assert "second page" in page.plain_text assert page.find_text('second page',header="subtitle", header_level=2) print("Plain text\n:", page.plain_text) # test find_header() method assert page.find_header('subtitle', 2) # by level assert not page.find_header('subtitle', 3) assert page.find_header('subtitle') # all levels # test find_all; all headers of level 2: headers = page.find_all('h2') assert len(headers) == 2 print("Headers found:", headers) assert "Second header" in headers[1].string # check that find also works: assert page.find('h2').string == headers[0].string print("Delete:") project.delete()fralau-mkdocs-test-6f20fe8/test/test_simple.py000066400000000000000000000113221504167644700215370ustar00rootroot00000000000000""" Testing the tester (C) Laurent Franceschetti 2024 """ import pytest import os from mkdocs_test import DocProject, parse_log, list_doc_projects from mkdocs_test.common import ( h1, h2, h3, std_print, get_tables, list_markdown_files, find_in_html, find_page) # --------------------------- # Initialization # --------------------------- "The directory of this file" REF_DIR = os.path.dirname(os.path.abspath(__file__)) "All subdirectories containing mkdocs.yml" PROJECTS = list_doc_projects(REF_DIR) def test_functions(): "Test the low level fixtures" h1("Unit tests") # Print the list of directories h2("Directories containing mkdocs.yml") for directory in PROJECTS: print(directory) print(PROJECTS) print() # Example usage h2("Parse tables") SOURCE_DOCUMENT = """ # Header 1 Some text. ## Table 1 | Column 1 | Column 2 | |----------|----------| | Value 1 | Value 2 | | Value 3 | Value 4 | ## Table 2 | Column A | Column B | |----------|----------| | Value A | Value B | | Value C | Value D | ## Another Section Some more text. | Column X | Column Y | |----------|----------| | Value X1 | Value Y1 | | Value X2 | Value Y2 | """ dfs = get_tables(SOURCE_DOCUMENT) # Print the list of directories print("Dataframes:") for header, df in dfs.items(): print(f"Table under '{header}':") print(df) # -------------------- # Test parsing # -------------------- h2("Parsing logs") TEST_CODE = """ DEBUG - Running 1 `page_markdown` events INFO - [macros] - Rendering source page: index.md DEBUG - [macros] - Page title: Home DEBUG - No translations found here: '(...)/mkdocs/themes/mkdocs/locales' WARNING - [macros] - ERROR # _Macro Rendering Error_ _File_: `second.md` _UndefinedError_: 'foo' is undefined ``` Traceback (most recent call last): File "snip/site-packages/mkdocs_macros/plugin.py", line 665, in render DEBUG - Copying static assets. FOOBAR - This is a title with a new severity Payload here. DEBUG - Copying static assets. INFO - [macros - MAIN] - This means `on_post_build(env)` works """ log = parse_log(TEST_CODE) print(log) h2("Search in HTML (advanced)") # Example usage html_doc = """ Example

Main Header

This is some text under the main header.

More text under the main header.

Sub Header

Text under the sub header.

Another Main Header

Text under another main header.

""" print(html_doc) print(find_in_html(html_doc, 'more text')) print(find_in_html(html_doc, 'MORE TEXT')) print(find_in_html(html_doc, 'under the main', header='Main header')) print(find_in_html(html_doc, 'under the main', header='Main header')) print(find_in_html(html_doc, 'under the', header='sub header')) assert 'More text' in find_in_html(html_doc, 'more text') def test_find_pages(): """ Low level tests for search """ h2("Search pages") PAGES = ['foo.md', 'hello/world.md', 'no_foo/bar.md', 'foo/bar.md'] for name in ('foo', 'world', 'hello/world', 'foo/bar'): print(f"{name} -> {find_page(name, PAGES)}") assert find_page('foo.md', PAGES) == 'foo.md' assert find_page('world', PAGES) == 'hello/world.md' assert find_page('world.md', PAGES) == 'hello/world.md' assert find_page('hello/world', PAGES) == 'hello/world.md' assert find_page('hello/world.md', PAGES) == 'hello/world.md' # doesn't accidentally mismatch directory: assert find_page('foo/bar.md', PAGES) != 'no_foo/bar.md' def test_doc_project(): """ Test a project """ PROJECT_NAME = 'simple' # MYPROJECT = 'simple' h1(f"TESTING MKDOCS PROJECT ({PROJECT_NAME})") h2("Config") myproject = DocProject(PROJECT_NAME) config = myproject.config print(config) h2("Build") result = myproject.build() assert result == myproject.build_result myproject.self_check() # perform an integrity check h2("Log") assert myproject.trace == result.stderr std_print(myproject.trace) h2("Filtering the log by severity") infos = myproject.find_entries(severity='INFO') print(f"There are {len(infos)} info items.") print('\n'.join(f" {i} - {item.title}" for i, item in enumerate(infos))) h2("Page objects") for filename, page in myproject.pages.items(): h3(f"PAGE: {filename}") print("- Title:", page.title) print("- Heading 1:", page.h1) print("- Markdown(start):", page.markdown[:50]) if __name__ == '__main__': pytest.main()fralau-mkdocs-test-6f20fe8/update_pypi.sh000077500000000000000000000024411504167644700205420ustar00rootroot00000000000000# ------------------------------------------------------------- # update the package on pypi # 2024-10-12 # # Tip: if you don't want to retype pypi's username every time # define it as an environment variable (TWINE_USERNAME) # # ------------------------------------------------------------- function warn { GREEN='\033[0;32m' NORMAL='\033[0m' echo -e "${GREEN}$1${NORMAL}" } function get_value { # get the value from the config file toml get --toml-path pyproject.toml $1 } # Clean the subdirs, for safety and to guarantee integrity ./cleanup.sh # Check for changes in the files compared to the repository if ! git diff --quiet; then warn "Won't do it: there are changes in the repository. Please commit first!" exit 1 fi # get the project inform package_name=$(get_value project.name) package_version=v$(get_value project.version) # add a 'v' in front (git convention) # update Pypi warn "Rebuilding $package_name..." rm -rf build dist *.egg-info # necessary to guarantee integrity python3 -m build if twine upload dist/* ; then git push # just in case warn "... create tag $package_version, and push to remote git repo..." git tag $package_version git push --tags warn "Done ($package_version)!" else warn "Failed ($package_version)!" exit 1 fi fralau-mkdocs-test-6f20fe8/webdoc/000077500000000000000000000000001504167644700171225ustar00rootroot00000000000000fralau-mkdocs-test-6f20fe8/webdoc/assets/000077500000000000000000000000001504167644700204245ustar00rootroot000000000000003a55970705c2b41069bc4ca952a97deaff9c1f83.paxheader00006660000000000000000000000312150416764470020533xustar00rootroot00000000000000202 path=fralau-mkdocs-test-6f20fe8/webdoc/assets/A sketchy, imprecise black and white steering wheel of a sports car, as if it had been drawn with a pencil in the style of 19th century lithography.jpg 3a55970705c2b41069bc4ca952a97deaff9c1f83.data000066400000000000000000000454671504167644700174150ustar00rootroot00000000000000JFIF``ExifMM*JR(iZ``8Photoshop 3.08BIM8BIM%ُ B~" }!1AQa"q2#BR$3br %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz w!1AQaq"2B #3Rbr $4%&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyzC   '!%"."%()+,+ /3/*2'*+*C  *************************************************** ?8RvsI-EPEPI-sEPG4Q@Q@EEsG4QE9(旚(斊((:(((($x.–2kHJ<8KXXda qE7S2[H 7G'jv7/ Ko;fKslq>kAJ߆<3'Kx\7ZCN 95V|YΑj6BR4;U#U?μ_SJH{L/ASW#|KjRp> z~!il ,5ͺPȌHp)$?8[:Z( ( ( ( 3E:(({W&i^ȿPq@5v%sIo.,nd!#RFN>IXZTTKwrJZ{ryB`ԒE^tu bd5 N|NbO43ԼAͨ'j_T)7f2\Vv2$[[-۴6(AVD?t;-ԩϱϷj~n5ZHl ZmX"n8U!<Ff2[N4lPh󞡤3ĨW1^ՠtpKG^zg}ž.?)%+A55j'7&l+ ?5Xi0WV,o~7i U:X6- ,ouI#ht7^\B!#|̇q^q\x~b]]HU <WAכȮ k$xU=z4Z}c}mڭŜD$ H<;ȫ5>91XjCtE{FAM\4\Eg88 884#bu7vd ?h)X U!Yݑ +Ȧ`X QQ@ KE;-UԭlRB*wğ fPZ6u5*H[Yݕo'\> Ga\%G lI u$O(+Ѭ<{O-{mw!. <6/mIlG#jH< :"sC9<?+f ixn%Ic q ^;biZ,>n#$RiҤR`܇W iZ$D9+~##wLG=~+!õ 'B_ޯV& dc |-W3dKd`{+}"B7!|?A^ttV-FAV݇leH`[ j>,h'6d# P 5 cuJOuv[m_zdFl#vFAX>8ፄϺXFcN (^dRHxe _/Ykx&PB۶F;Wо ,rWqW%ܖxcV{iW e]!ASzCY|4۳IyeP~"`S‚;Eyn9'%}y^LG, 7"4u# yL:r"gJKb[z\c 1`T4>o ]5ɏ5coGxsEg+iO,kaP0!tfM`,mㄑ\Un;zHu%Ko/!_2h@$OrZK`ԠHW%òiAp7 m_qwKw+']V}K-`BC?OFsnAXןk_ ti/5PʹmHUG!8r|egR|WTJ=[c+u^ku c =:f]Zח4iO;/\ǩt%}@? :~xM ݶp#L4r1Bs}Mk^({ A00#A.=b~՟j.MD`~|{4TӴfnsP vTX>!a3m'dAp񶾟cC 1y3ڶu&-dd:ɏ4E 0~؁smCRFe`-’MtoB~,٥yHG`I\Zθ[nV0TF~?wߜU:f2Y?]:Eޣ@y" QkI4.gw$: *1G\n _źL7::?.T:D \ 3.=(mh12q5J<#s1W+m!PcLa1U}Or+MSr#e͜ U; zE̓iesK[X[|}ak ޠ|XΚ!$7 T.-7^8h%G| gg[Y[k')e z> okVЖ.lK{|#su* 6|E'U~+}s$[9.mOeۗrQ 0Q<Pz /lNINFCG$lJM_m&i|B$L zjԶu>%kI`Y!Hcݝ}`΁ioj#y'f oߗ'=:kD2~^ףMKQG6}2'ld{GF-ma˅]p==ITşLC0G.ͳ hy< КW@Oݼ'Q"7FRHc$ߣXVh&*~E s^KZm3lW"詃4q/zPb!&GsdzzOWee~i+h:"D奅fK1g@ s$iȞ]`@m)HiGW<džΧ<(]A rȍS'F] P-wV#^H5̪K2 QsנO%K1GXF~IhIoz;T6z[t>kqN d~UW$At}=̶Z`fX'м1olwq78I'W;=Q "&B‚xlMp>33\F}l.He;-O1B;)R7_lzxP.aёq7' 8*9f1L1I'>ͦ ݥVw?:<`p Њ$K;dHTXd7遗{I]d=Zy?0)%. Md .o ;r6| g.}5Ƚzd(acVCa!XެRAR+cCDo3tz>es w+Y~(ӥgK4n=q6DSoy$ogkqiRܨ'ӎ̱[xG7-RO0=-ߢ_Ikr$1͸. ,5῅*ԵXV{novDWS#BF m[\HTE7 v|>#ŴFKo+FV=b뎢T:V9.t/Ow,m};~Um 0MVյE7ֵa2D6ZCPrǃT YD{Ҝ& ~k~KG?1&H*ԏ_LM̽r[CVt~0.ւ]:i>n2~dU] џRnr E}R@ ;p}.o%aT]rT-9!=}[mWxRdXdx¸XfUD:f w-,n&1]6"6|s#iGcҍJc;1?Ky~P{->w"c5 kVG uOirZAdwKԣ-.l/E9EgE=3Vt]rRGH 1Гw]e6,y |2ҸKno~g$G tׅ}2Tp/bF6ؠܫ~ & ͡j dHp'#½*BuGᾳ`ƨ}(NPGA8fI|rMgO{i[zȽ >=dn8˫^6hX.-A;nri'Ɨ.$6Z@_AlYL4Rk#G70HB7ZWcnB墍Sז)CLis5;n8g  %Ad17^ayIپ??`TIX'<:Z:DKsunR8x> /@ȹWpjN}x@bgL ݕ+^\}WMp;.Pݐq/⢻ K"+kAdy{XkGSQ.Ikuv$ZEq&Y\ &8?tN=~I|Ac Zm6{n n_x?Ki;|JK`G q\moiVZ}PO+vT^jc/fEtSab7<`oVGd84#FT 3c"c Kh94lwR}BYGLosz¿g7 aNDe3}|־]ưգl_N؀߅+kf8fXme#ԯg#֋ׄ~(k>I'{]Lc{ZMRF熟Vw/p9FFx2Pybgܓ* 2?+{cs |OsO6<Nz$EAL {K{ox;h}~|Vӵ-d 8*wu8f=WZM_Mo麞$wmjre1g.89VEd\ܭc뵃!8gҮӦhlgb#09 ࠓ0pqFwvkUKiW2.rJ|Ͽ51.Gt0K." Qp@ >=2QěFy?z;ԱA{^xmcÅud 8?cgE񽿇}ݔE]FȷGM08#x^|ofi|n8'8<搎C6~nlotZ4;$AF<˓׉\SKm%ȭWy2J8z׹+7z-֕{-Lg27qU|;~t[ZZx%8;rX;NO3ѼAM"1+x9֟babr?hK+20ns1ÐWGaZ_%~# N}q@DŽ|=kE~RvsWQT%{ g2YCvTf YV>εWymC鱚ESkt:LYpÝR &omAԹwaHmB{=f]^%ѾɫvڄqN>f1 WѺ%2}7DV,ʋ9k>&Y]_:}n9;.<jZ,qC~ԓ^aG#dz|Xuqz<#z[۷) xG0&}bOTA;l EHzU-nKw῵mEUQ y.p[xLk^5ϘeE!6R|̑D?h7m kEY.nAaryIwm_ֵp)[Ta895u4ckm gdwB)V _ӟ~zVPPӑ<S Qn'c6x `r~8M?u,ĐhHҽ_=/f⬗8tVlw'?эt~,ek{,faq$v+>a=r:LN8VϾW`tu-|"ªjw?Wk,6dE(qp8Uۭ-Uy%ei]Č@)LjVt 7v_v ω4Z\+t0C݃V \NƝd 0S@MxdK[\Y1I)^v~55muŻmq5r+⟈{3krl FzOei;[l8b;kR4 tm" \4_~"[}A*ʤzmx%ZuMenbuO:bD3%z,mA,|ysI$kPX0s8&rx?N%{u8DVνeHgݎw#\DoWHY"kp@H<}:{ aimƟaV h0>q\EsMm⻻nخFN3־MSL_ ]Zm<3 9'v>(:Zhh>Fv*GtlzзIwZPH|ϦڻVNمv*#ix~YӉXgu,XN~docl"i5-ք#y ʪ c(?!@ƕuXE=͛fe,9'SחeYyJҌ`u\Ωgj^+k\rb4`Y:_D2hk692ʠW2>aP9Q5c%Sb;TCi`O NO&Y2矫c,.G59֋Pԉ~D=yq`oxRѵw4*ǝ wpURF1/="+Ne-Ա;sSaIkZW-icnR8n#6E$dL.AM4BUMdnc^pH*:pS0N n!Rs~z8,$|~LE0.k]ReρZdHM,I'jh#WYEȏejA;`1{[Ɵ5o7W+9NXcZ|GawƯ{Ͳ@;ݔ xy Ndl>Q8%^o3o!edyHkεyjHrȊ z킧9hG޼U 6ɿTYntl!$ d~>pW>%MVFpA ⨵>GV$i7өh6?7IA ^B~#@ ۃUz681ĩ Z]vK':%E&$BzMA};7Tc^¥D 0O1Ga=Z$fRBxLZ/7%! Oq遟kjvp;#y2 +|a[{ۤ#P!8ƧЄA3oL#c2H{]9i%ay…<gq4A[|6|_:#X:(L;l J3z z;,T#LL=z.=vוjfuMŖ%sިX'¹dkO ~c%'퇘ޝ+7"##@Vn˞;gZ#zƪ:J.4:O%ofO~3On }9o-]%-ь2 B4n\3 O4g f$t zEYMp[ 7,VG8cM2 {k8q~\[0q=Wo>gWk'?v%+tmb]*>Ւ6`!\PizRT_`UnKgq$JtSڥd+1N0pQ{^(gd9 ON̐C1Ogs : -&i>hV{y=kj4;np =v z洪'F yWqHB옔qמj+66~a o&4xB5Rm)գ6.r^Sw~ Gj6~M܋)p%9\ an@JCepYlg# Sw$@1j`1Hddb'C c#kH<>^58n NL%iñZic7U*C ߁͕قgf`CBGrAҽ夺F:~T@ W|F߅u2a mYgɐzPj<_;H$"'(b09\;{f·GNQh?9S׷ZG$i:tZϵv6㏺I:gB ʗ$#=MyǍfR؟&9ɔ*dSp~޼_^ s,eJ&Ak7QI-mXƙt~E$YYNCa8)!l~[;Ԁ)0*V#n> $ҽAsot5̤7!I6ʜZ  :w`;hgZ|KEHZΟԬ*I^dC⋻F|3M^?:(-= l$Ş 3uύҴXJY$!\sʯ;Ot/YegyZkb$]=X9'Ҷ56r0v9%ԏΛiW0nEKQu|0AJX_ãV-ԂTn8OIdCHl[8i Pg{rᏆ7œ< #C'=Nq]4^F>\|ǜ `p<5DKcb\vae//y E<@p8P{+`0p9}7%hTp:=rNxkwG Wl`OnTs 9 R+lar9z-z7ktHlt"]D00҃J+üW-KHS%y_|+;&+!׊tqI+m6s5B5/6p$)`2}z^`e!J(A<뵇_/^I%;hcw 4Ā>nxSLѵB@}&24 | 0<aUcxGQ$~]5ͱR2g|Hur #_ZxÖ>-$7WVnl!.{|yiOi"%9 24&%M/ [)kO '+p+ܾ%ǁu/MЮ QdI H_ +SVy%]"ۼrd sr |2T.5/)'m&XZ%*ĊP>dsI|3C 5'ͽ;Tr08N~xsх[Scɨ;smۈcPۅ2k#] J 'q q㊍Dub'캜#w.[ _7vhUOIV=A`x_|cdVK{u6[UBxuڇ542 c$@짎j{osd.p o9GB8[񸶷}dۃ+8R:csWtIʙ?3l=9{M;ZjlCʉ6pbqB}3\ׇ$ĉk>`Q1rX 4b4[+6JC1:R=7¾*0g1+WB S)clmc?wGQV;h5Q/j5d&Y1Ss]y Eɹ1yDZ5iB˧Alj4hbxݻ?W\-nuQjXN\H3s3*|( 0?ZZ:-֒ekiG~ E@s\ sqx= FpsnqGoqOשjɈ fpg10ODsb&|ߨEsq InWU U'h*zaz(I42́nsW RL8x&ͤ Flr~\3'l)oجq?ϟ.ݐMmpԌcN[B9ڧ5݄i Z¹j/Cjp9p?*e+i@emY[,u6D 6!1?MHWerm4{-Kɧ:-.z;I#ܟ^(͢6B;6rJXxFoZYE=ݿi;amgiͶhZnq M,~> ͚m/uq>p!=o՝{]rAqdKm'4#׾ ]%S#|dێO^귗kY '{3OO{19=Ir-%`O,zyIkMEuk,yV8#2Iϖ<m?LBrD'xw<xKXcTCFn\<.,A?;:(`v} &]: CNA>拼)>Zzǂ#&˫#< QpN9<|ž)ɷmͫ !r8tڟ,C7`c$5Ηsmi',sk drBFs@4?&(bY4`| SPGu{oi6Ii} LB03CStѮҠŸF$P>VtVE#U_%AQBzށk㶵;k gtO#cۡkW!vl srcFp8E:pd:T1B?:-CPUA"R䁊uE6G=W PS,qh08֭ûKW?b|W ^Frb~G5k4 O^j6l}0'c{Ż٣2Ȳjo-Ƈ}77DKu!lM9IX `H;>xÏyrrKomibʒ%-mj\!sAf=6瘓Q²ۍfL'r*ﷃ$Godu!)bFl[$ /5L-U@@y4]/`ge6㍬A5-i]4$VQLgA`Wsu-! KaљD҃| 1D n7gqMܱ#'ij]{4lrrToU.!CQvȊaX̥cRHr}[#u}7n { 9@_zꑪ7WQ&I ڳVl\6MK[.JD|<ՙ?gAtRa<'*J.(m%Ԇ^'9=VFֺZIvX y2 vG@ùQ(JZ(:-@((#y`x╡f(Qh[Z1$ۊҢBJ՚GYve*vE^6m᙮"29F,A+F.xSO;9BG:0 Mdj&͉d1c5;:L{zNp i:[16qwz*˷݌f>yߚǶum:XnXix#߽{}@DV;$ܓVe'f[wo"-A;Y#Gf6q[м21=Oóxym氌[v }{-ml7HF w1OOR6qJsƙsj}Somm @ pytest =============================== test session starts =============================== platform darwin -- Python 3.10.11, pytest-8.3.3, pluggy-1.5.0 rootdir: ~/home/joe/mkdocs-test ... plugins: anyio-4.3.0 collected 2 items test_site.py .. [100%] ================================ 2 passed in 4.48s ================================ ``` In the most basic form, no configuration file is needed. ## More advanced tests For more advanced tests, see the [specific section](advanced.md).fralau-mkdocs-test-6f20fe8/webdoc/docs/index.md000066400000000000000000000055331504167644700215110ustar00rootroot00000000000000# MkDocs-Test A solid testing framework for [MkDocs](https://www.mkdocs.org/) projects and plugins. - [Github repository](https://github.com/fralau/mkdocs-test). - MkDocs-Test is open source (MIT License). ### Why MkDocs-Test? Since the beginning, the quickest way to check whether an MkDocs project displays correctly, for maintainers of an [MkDocs](https://www.mkdocs.org/) website project (or developers of an [MkDocs plugin](https://www.mkdocs.org/dev-guide/plugins/)), has been: 1. Initially, to run `mkdocs serve` to start a local web server, navigate the pages, modify the Markdown pages and watch "what happens" (interactive editing). 2. Later, to run `mkdocs build` (possibly with the `--strict` option) before deploying the new version of your static website. However, a plain command `mkdocs build` has the following issues: - It has a binary result: it worked or it didn't. - It doesn't perform integrity tests on the pages; if something started to subtly go wrong, the issue might emerge only later. - If something went wrong, it doesn't necessarily say where, or why. **How to do non-regression tests, when rebuilding the documentation? No one wants to browse the pages of a large website and manually re-check the pages one by one, each time a new release is made.** One solution woud be to write an ad-hoc program to make tests on the target (HTML) pages; this requires knowing in advance where those HTML files will be stored and their filenames. But manually keeping track of those pages for large documentation projects, or for conducting systematic tests, becomes quickly impractical. **There has to be a better solution.** ### How MkDocs-Test works The purpose of Mkdocs-Test is to facilitate the comparison of source pages (Markdown files) and destination pages (HTML) in an MkDocs project, to make sure that **what you expect is what you get (WYEIWYG)**. MkDocs-Test is a test system composed of two parts: 1. An **MkDocs plugin** (`test`): it creates a `__test__` directory, which will contain the data necessary to map the pages of the website. The plugin exports MkDocs's `nav` object into that directory. 2. A **framework** for conducting the tests. The `DocProject` class groups together all the information necessary for the tests on a specific MkDocs project. It contains a dictionary of Page objects, which are built from the `nav` export. ```python from mkdocs_test import DocProject # declare the project # (by default, the directory where the test program resides) project = DocProject() project.build() assert project.success # the build was successful (returned zero) # find the page by name, filename or relative pathname: page = project.get_page('index') # find the words "hello word", under the header that contains "subtitle" assert page.find_text_text('hello world', header="subtitle", header_level=2) ``` fralau-mkdocs-test-6f20fe8/webdoc/docs/install.md000066400000000000000000000012401504167644700220370ustar00rootroot00000000000000# Installation ## Installing the plugin MkDocs-Test is an [MkDocs plugin](https://www.mkdocs.org/dev-guide/plugins/). ### From pypi ```sh pip install mkdocs-test ``` ### Locally (Github) ```sh pip install . ``` ### Installing the test framework Or, to install the test dependencies (for testing _this_ package, not your MkDocs projects): ```sh pip install .[test] ``` This will help you run your tests easily, using the [`pytest` framework and command](https://docs.pytest.org/en/stable/): ```sh pip install pytest ``` ### Getting started Now that the tools are installed, you can proceed to [write and run the tests](how_to.md) for your MkDocs project. fralau-mkdocs-test-6f20fe8/webdoc/docs/logo.png000066400000000000000000003570511504167644700215330ustar00rootroot00000000000000PNG  IHDRZ=sRGBeXIfMM*JR(iZ``l pHYs+YiTXtXML:com.adobe.xmp 1 ^@IDATx`UO&i&3ݓZ6A/^"*(p(٫QF[J{%M6I~}n'i)8y~sgKhHExhikLo875Z59IJ[NkP^ϞiBɲݖ-m^b,9ydd CLۭ+ʊm m&g1{%4Wn+qĴ{K߅t͉)Z^Ŷ1c?[5fGz(Y1ma9?tӞԨp -)OiAqfKƽi{OiNcc/[҂Yo͞l_p5; DNGB4ٽ!oViZTBMMVg=S S&^eǼL楄=G idR7OƌZ'뤞y Dg$ BEѴxbxF9RˍF]M_ijޟXOL>q@D[0_)%C2>ԮdgnJ<}_%~8y Z<(v$O jI”lD8|JzbZ+Ydjӽ_yغJc@|4KزpLo8pH;ۣz |hn9o\3/8olϜZgJ ț Żdy<(zE*16qR ;F%B)+平"JϠ+'tE f'5Kʳf!ωEn&m+rYHV1x-]Nژ:1mrM|8'|,x"}"]E =o+idVb>?,+5c(S$%ji^^)y<{4)]"mlV7X7C%B9sv} K$IVĂDTDgDBͪn(~}d#ҧ'=z>{Sز]-rc2Rē=Lcaϩy,:s (BJ@ԣ=x짦iS'<0qC8ŔME{ԧ!]}˫b,y>u>K{o1o|9 S@uٌbƌys=y˴9׼ԁY{v4-':A:TcxX':5=iR{I~GKx{[뽧e_b>5{KǼ ޮS2}Ӑ܎We:O}VWUl^ޠube!n.9Ѐ n+@ڏߺh ӈR,t~cMs\?-!>^x9>95mg^﫜 3NνIz T˚5u<ݧe  Oh]D3&ARSàmXtUo*&sxG^x!CжZ2uWJҽ7%PKuj}`t}"-7)W@"@FaVٕ&jixUPqMd*'λ9'2lN:>h}? ~O15&ueuH3x͐14sũ^| \nhlFV %8'y/OӭHcx86& FTY9Ϋ}"zjz~&uĝH=N Ix+;"UjnNҳHZDkJ {TZ}!?˃Dma{$zHoBX b'$SC }Vљ\q23BTQ<84=2Ma*-S) U[_kUV[[kUV]Umvkw‌1Iuv,mچ OQ䴱uΝ&ڵڵkkFϲ3ԲLI ޯzk`>$o>} #Ga$KئqBIJB L@=*++ղZ :`;w:YŽVXXhy6Vo>M4Ӌs1u<~@iJMRt݄1Y4?$$H}Fo=(`w.*ؠPV @4ZEe#llR*D] D{iݻta:Asru6lSz:+G5O˃oݦyZ(Yqqr۱B:[p_Aڪ:2{6d0`[ݬSN23+`D5klOIrH.M=] JsdIYG&\3"rQ_gf#_z۸~-Z̝ms̵֝:u#2AU;t`y@{;7]CdtKZg߻mQ6sL;h!xإmv;餓Rd KK;Y/^ls̱yt; =z :L ֭_Xoujr҂5.̭Tq<"\sc3X{A@KEV'@^paw76+MxnK-wޙa͛Ȇ=ƎkÄ(]X~lݶ~gjd_cj*klqnF nǸ[Btk߾~zv/;JߥE%iOGmMmʕh"5kmٺI1Y^N~SֺZ c:}NMMhiv ؄ m谡Qgag;wZYYZmUu+6o*]΢oFoBu7՗_aWFJ/U^j=OY77Ob"]omtN=*7X~t lƌb ՙ/j7A,`%<e>(g _IH: ?Rgh!uOћ/nhc2Δ u2-]^}U7ޚk_v;UܹIJOY NH\SZ----n\ʲi&.j]J6n)N[nݻK#F8ʕu?'˯٬9m l.Nv)׾M[jm$w(hg 0JݤJ@XSB=h-ڶk.)lZlKX2Ν~ĉ7:35 YTpIw?f_FmҤ#B "9L@k.}}CS 4f;f?lo >k^%֣GOɮ, ņ.+\..^.]+xkkĊU2.Cz]lUw(H9ÀPޣq( :C޽yeE[V+ *]bQ|7TUZU3h˗."d~|%qCGWK):wCL~M6nobkAt< @,_'C=c?04!c~}MlWĀ\RVFYN(nb/Ml?VQVk^v+ /Aݍxal;]"Df̫׬vY*JI1Tm%%6!zeeݻ#WR;Pn+:x#_W+ϪUkLdeҤsTVP9V.䫕 g[>/EATXTX`r}T)CW6 i)nyޥX(ҷ_,vCQQ_Gj{mxQ6eˮ;#{*fKxchMVa^ُ@rU[{93hݻU`QW^e#v78S6ֵ[ ,n[|[b+mV O({SNB΍7bWRR"rw/>(Tnymَ?3#Sl^rkصz{=#V*av!O;3򮦦+rNԃKeZv-}.^f\t WUUG: ]Oaau9Og{I̽F[|z][?8:Gr<}#9 )DaO- 3cI҄wǐ裶bط `=F:38Y{n/9,;`pPw4䙢,]՞5kmbC1oRVZ.?Mt8 M`[}XS.'SíZ<ȉgukp]AyW^/B]N6 |qL_?|-RekEQ).alF&WA?هҶn-s`G7:X|DSV}ܸq6i$3zCY8o<6m'x YufdGq Oocs*\ ~|ω{ΔLeUvB,$YCEb dDԷLE%kl(ڵk. IU>^]UcVRE-CH%jUTBvl ]}}SnaҺ@z9jz֯z l6\x:pmX g -J9WVV)os זeWy3D6eTֻz?0)(No;c7=d'lEE|ċn'"bQn;'|[m))f3yz-y,߃ ҥ&7oXvͅ. R29bGad"7+7h;W_%[H8!s؜%$&qdgv^zteҌM*bi+**gl[YYm1o#cgHFN\NXޡC{qv}zi;!fUTNÎ8~[uW_{;l#?+Ksͷ >(AE-F@kVޣ6ȰÎ^={1cw$sH_zɆ!zQl!i21*) ;u-G\MsDDU*ԀBDJϱe%;~a|{g;,R5y[-\NYE^`x9 68֭[/YW.*lrTtɋ\%']VS&>MT7 } Zq=#n›`M?{q;s\*-;A'q- 3vaǡAjSN4K/vW3y;=_=!{o"Pw\F?A96˦Of/: W}"x^nw3Z%x> Gɓ3K5͘fW^qx)vŕiA)B\~yA 4~`UboQX SG=B!zRQ<*)U2QB2}ŁPo !S"9|ޡk$FTq7n^og;k_xsOST{uJ 5&΃60,,l'PrUV&J-wO@&qqFaqC@%eK,!:w@Ę*q.;v2%c{mݠs?O/XrzLٴl!:YBDI@s8N~O}mFk_jL?&#I$9$k/_*OrݵNǍ^rhlD6~{Q&5k9EGQȱ95QrJSڊk"; 2 +Yd9m|OynRB (3 G Wb{ 1X;(zwj,3Ȇ4!w0[hl  J5ΕՈe6⍣RyX8.\P"u1][]uw<ޝ.eyoO}ծ 2r1^lݾ_,mm;+ECZ ,$zsdFD+'P@ѓaMS 7]}4r:_eL<_jg| G|K)W&|8w1u"48f6[܊܎) o2vk#;ÎcBǻ^-4.vm?;4h`z_Edp|d{/ҤcII){띢ܸ2CiCaPeV)k`+W-T6Z՞,u%]s!nEįBf6Jkߡ`%6^ EINH(+?ղsG"@dmPX_DD~sU:{CXա<3T UkUW^egyp Re{Hλ^:پ||PaRvj"ܸagmi2( EQ]-[-r C=OyQ27 >QǁV^տ4tsybEdsGXۑ& }<}xBBgS]`_x9;s1X$w9e~GƯ/⥎h*jCM֊j_J($,)Ԛ0Pe[飇i UCK;~lO>*O'"J'2 ͐^#ﱗ'O"R۰i{d:Mt偍G+ZaBUz;ƌŋ򕰽0p߾ٳgOdP<-yĆ*@̕"4A$ޅRJ>(/f2hl&ٶEn-LPY3K"Gm+,/?t'ʦΕ9Ǥ1"^a f&4-8IA"JӖ25;48/R N)[^@_I+QdSW_hUqFlmK{+/aCFjRJ 8.)6tyul7_zO>d}+**rJKElYYPdQ A^}S,bN%Y "VQTNm {=⣪/ǝtq1x`߿D.z)OROdb8ի6nVcoZjuF@Xϯpܹ@|+C/}|6Tiv ;sdW+t92o:L /_ 5e˜$Bɣ_GÓ3OS[(W5qIJ㵏[dkF@4u qN'34>2ي6z}ߵ뮿-3$1IAؼAb7 ZQLvQ,9w,*@2D5ˬf'Qzg|R+wqة?E^jD= I*ds ;ө: EGQO] *S#ΕA)! ;a$/+VoۃoɯOnÆ S}ZC\Tv\f"֭PւYO?Y(aE} lظFF7o|wG?‰"e[laB#)j Tñk]+C ,Xhd};'ۛ_yƋs Q- 1Ã$ﵞ_ym߳LA;":ĺ62^G@p)9Y,`ä \D%=3Oi}VeW KEG,֕\ZRpޚfW hwI5Zn0|$V*Rɍ~!ҔtsXHqӄxBrgilZ&+2 yxq ܇"d:p`٭:jn(ЧFwI8C P`vTKAnb7=Ǯ;PQk2edq r2}t *<%7>C%s?tηE-{'l6 mRlޞcPZj3J;(CO;UH_M:*Fw?ꨣ#wx#` Rot=7͙RbL][VMh<Դ1O8'0҇2)De\2CjUԡ0x_bp4i޶yP*:EY@oF(7m6Yry7oO||*sY,!sP hZd'lƏTeu*?E= Ib.mr3C }l+׈[im&*쪠' i w8cŦ/3Jw%&2uIsU n 0\Gk okoƯ}2ę%Z߃p,x,pH̽)ZD 虋+ >‹X6[[pdG2p_qn{σw*RGY{B('r'.AT0HH#t( /mY^юăpK:6_N85cSIrF4ù\Y`3g̔[`4CT>Cζi 27n^[ܡ]`- JWlXR6{liڬoL(;TtITU^i #[Ow*{oQ#oBVm< 3ޖzmK}U.\U_2Y V,4.ľ#%:d,:F`L7:I&8 GG?F5 RK ̫lUw pMvJɫ\K|"R*m#5S> `%nܖ^ aq37]u- ΕLW\`1ro˄QAMR=PDYr#M2Yśܘ}ݼi6v8E: Z'Rg\ޝ9G+GB*^#C5qvBmD@x  D'՘Err^BNiwDF@T M8Y<x_uĢ_(83?8`xCmke&D&2 ߁&xuWh,k>~z"|c] 8 Sv33VxnuWGٶ ?EeXWRy TN/, xouGyϵo}&<+$OSafhJ}%}Dn\N-C= + ΍؉rĘQs-j "<(Y T[ޱ8q'=zomU;&PVƒYI{+VwBL#@U) .[.F9ul%b_3Uuume5Xb#a јԬw;g٣g*$WsKe/maΗ_7}(X+Z+31NuxkppՊΑoQ^Tuki#x2\ jDdOx\QgZ4+;'pޯyw!y5FPQZ %z#xQ%-y_N'wIVlӟTG ? )jDfr;Y,{i7^&.'nݯ=m}Dm/+6M4uQ{^ې i~vؤwsG/mܳmQo:j(*բqr.qQ>)|qaLa,P\Ad̨At_{H\]Kʺ+UASX?:X2M  lN HDͩBk-d?.eNKwb @POJ`=+@ X}>-XT!hBhd 50 ;ڽVC^/q%ny"OjzJ`24R*1yTʵ12Ӫ}ɢ/t6PE(bwWn.2ӦͰ}wQ 2H@h~.9c|-ngL~z}kE Z>^0]1QTՅVP%A 9sfK+tM)/GB&,/W?9G8z=090 J@&r@zM<N2S&jew3~ڏX>(ac.{&r{[1Ö[:|E /㍲֣qabMx޾D}ԫ~~!),ݧK{>%z jQh OˋpGEHJXSsXerǽ+_wcWs ='n*3ףh>qeTbkEÇ/G(8{AY %0q߯_觱?4]#ðu>1RO> ͑GdŕCF7ؚ8~>ڗ#D,.Buַ+kVK߫+5͞=͟׮Z}lL|"*/hkb0l\T/v}Sߘ*Mng_>,O93Y0BI6}CpEDp/J =e"By"MZkUz v2<_v=Kyf&h&@ѡ.oA56Q43q(B(&kp>3v)d{른dB[m^nw1-6=WNNv߬ːwnJ3~_^uЅbDɖ7e{"5 5Q;,>Y / 8cpEE. d)" vO"n!nCc)">YNM.eÃ٥sq='DD 9ˍc)E2hllLm]lg߿8hDnܴYTTi-vm|}l݋lju b3l 2aB}#V jd1G;R>c,GÀ<.37ŻXg<{jq5r-}YA{V+[}(XE}{R2t#2K(XasvY *4/BM[$>e6O=vPYt&qrcd"{XptJP!H\Zx8" 6SDKYˣi$/qء`D}9F%EO-* gSSd‹|&hߞ 3Nwm}`%)vUӞ. %w!CܖI;_f9e)V56AT;S>Ү L WSDF`r2KDw j*yA9d|Yx-\0mAeqIE*brn*Gf#?`AHah\&T^dP\;E쳜!An/<wKgj=8zxPw3C_FPmֆ sy0:e@IDATQWҷwL|ZEa7HS,<殴LE{k.}'^\qa]uJ2]8[n5v9hq躥֥S)>U~l;{1"XSбLS#Ns~H7zLEz9OLHcnCy j٠tvԋ2y yΊqCfh}vX.\e@# wʮ$-2P2zxxϙ!Þd1H\ Kf جi% ١9 U"z)4(eұwxG^~V2ʫ9[]uŵ*)aߤ5;bFmxӾyEia*EVж >mFV`[ ]ER@)zgDHٙx ^Pp dU8K0H ^8@\N= l3kefڥDʁc}œDu &4J0N'ωR It(I@=͑KuS&`C'ւPΦWRr;xxK.DH9mT{sB8 r@c#hQ m : A2gkQȓ9O_m/-?`;SDP(0 zu\G,8/N1N8h69\bK2Etpj!gg!n޷%vM-#N :tb^"BȎYoƝQiwٴX[xR9{7oYoeтmԇ Puo͂N ) 9b[prH`q()sH8[C ߭4JrϴK9ɫPjK% R\Jl8T0^`{Qho . Py3j)PMUy Xaֲ!dJȖ'^xIQQv:P OͻPʕ EXrf}qDfO??Y[{Z!;fϋ%S 7irDt&3^ @peO &|`à@.%ᣜç^y|G$gQqMͲ~":͕ ,^CWMyT)ȑcP5+.E1н^ǶR"GI ;~.oe#tk;?3zaAdwdx5ZQhT-_JWwݺPYI_F):;' hk_(DF^eNţUn^vL8|aMyC@q xe\3|U*s dSɹi{g)'Xh=XQa9?p$E5qB6V#sA.]fε-ŊAKկia+ˢwn/]z>;?'IvRf͞gW_esT-Y lReDvJe>??L:'H膉 LR0NA2~X0}c^*'^&j 3բ ډR bv Y 72?RH769kl׿UUފq²D բWumئr&x1nz[-O^kxyȖPʡٶ-z?٬cAg!p<wk_ %}N՛ngHY \ m39O&:gjvZy:D%tW;-ym; ;˵;@y&kFw^cIHx ] 85WϨ'2qPv,R&~bPlSX OV $U [7_IIv.^xpW M Ƈ7?@5 0t|gGٖ+%4C q%lVWhδ{ϩ}ٰÁ)p3ԿZx/uA.%.REr-;}9i ) zhGz'K"?@'CN1qm7n,-wN, G|}fPgqWO˖W)=Zѻ_QZ G/s 9s(c/OR͜9K vΒ3&Ib'J% AmB֩H(TW0PeQҸuzOv|_xy>C|_e#,m'2YtCv-ߘmȫV"qBvG->*9k@#OJA҆TDOArGD8yH-BKhKFPd@ 1'%v!m}QawI}i˾O|R_^B۴a40_4tpwfh-$]f`rP!V S|A)4X%VvXi>hZ;^W9_|AҐOaǺIQ-z؝(6m}zu1lhcXiT38 =x&q%OfùȦPAv;xQ3ڹZj.PuMsQ-͎0ѤQ{+Fdv$' >|ksLlk\(4s]Y2KGni .&Mb_rNtE/pdbLNs.ӵ`<rnODRˑH@[ĶiBw풩Nفqo^twj4Pr}@Bu`y'D-!fo<= :<"$1t^*"6_C/N!Kp_aIL)IGjeF:'s:YR8|U=6 2DP]-R BrX$CAE:ExfoO]}X@lvxWٗdd|[k7R[J s?/\A?z[ݡã8IS[*jV*\mo֢|BB-[i'®z,:E=HMH1ݡ׶dP)%׵rǽ4Y2Qwv=wʠ5gH+%`2$NLZi3qԃoM> .TI'[[ٝ\Gn!wxkc#q{R:B8gƍJz.j{U#ŴL,$ySQ"ק.v+u4֢m۶e}qk|bz": 9ڢKBCSvr}뭛Qs^2Deܳ%BZ}<:JËO>D} i]zN 8\D36tJ sY]yy-%~Cɟ[H!:`wCaJ1 /^fFH.wVShEf/48dzV֕4c'?셦QPkߙ3{Ξ|)pH 4p [y1Cf+r +C@gE ZSd6vꪏ'V矱+F/EE=E%`Y-;=hbd.He-XvmܶNdL$B("vu^ N$ \ 5uX};נG̪vy¸3O-|[.Y)%T+WzfϙI{aJa|O p~R'fhQ~/GR?H|+QʍBD->X֚Q8 Z~xq{=vW AZ^E2U `/h/[@wM&@b^CYpz ݘ1cLߤvcKVs'81̙ioi"P_!w@ja[gGupCMO($"7AG`)9v ZX=̬͂$Ӹ F?Gע(r&7C5I8&lm$&yٸh\i߶ssw>SҗDn_j4N ڳ=^f9kTQ5RͶ :GS &1tV}x ^|4a^h?v<^z%mP@aGXmP!!PPD`_3~雥`nHQY2[ַo?Ɉhg(7l@fE; 9cE;,rT_6W^E}ɰ=ƞy]h"∉;=u;u\Iԝ:)'a ثgL/hGRRrγ/B&^??ݻ$A(kaxmDAKM3@9.2]~eka#B}Sr4d4>HW^=DÇ9A,$,rkMYc>Lܹ uKtkդS[vAreLyO`&̉vrzCfA'`Sb]@`zvSpADŽ>{qeo#`\py[9 00n4TS+9w]|,| `OˌFP˗'O֧J^E~;9LINn +Y1G Ho/(L)e@ǰȑŶ@-W^. {L!y6啗D}I;lJO~;bA);w֭[AFH+vN%wG@։ ب .DoЄ[u2xsNѦ}ϸ,7Ov㴆lmw  g_]ӽ"O w83LjmUup?O:A"vgHU_|n*‡E\E~BB-_"~ڕ.GzC Sg|?%B^uj+1.B-%>w߻1>#Ri/;pX 9EeS}{JmwS/Nf?x 1 0!0dc 8{ 9@1Cȣssˊڇ^(XOpu!`, . \$($'qp-A}5σL ‹Ub\פJ@_V1ÃGs\㖛n #b U,/;tZٯo}7,ZHr_|.Y22a$IY~M!Qꡠ7NJYB9@ts3ϧ^hcx+xO$X#Wh]DeE$Ҍ9{d" ¡W}Jդ(^j/ġ_Xr4j.AQ#N4=fTR>}#z6w a}u6G9k#-Ƃ5,F"4baYh̹\peX~-襾@$90W:ijt${ӟIZ?v}( 0ذ|E ˵뤕bD戭|诏(&QBGBd!&"$ll2a2D/,l+{ Y`lnN4yPm!g(e)7k z!'@C8~z۝B92' .pN",;qQ 9<~ o7,|X% r+AW 8 4UY6hl|7`Yz!}DAMV6ev1xTC9L2'7n{Zڊxܷy@,_7T%XE%qJ55XʓvwW,)RޚG#I~}AQYuIlst@]ꫝe H$G^ˤ(t*,pe@"*삅\ 42J NJ_!/\!0v6},WɟZ|""'-&*[]ໆU2hAnt]+/UlwYFPc9p˦\(jEQdB<\er^N2^U% y4#+)e›p2ddb~|dm&5mLlY$gdIݽD2 qQr!&N&ϢC;m-vzv),Y{m(qgʔWgOE;h8ŧr_uW'Dܪ??,\E)K ,|ÂXDEbo޼Yi|QA,:vb:Hhgˑq D@lbE Wx jJ ޏK/ $Sq}" wuʓ%=oك~zv++ gf;[% Wnh'S`Oj*21ZM2!{B v Z[H{:ƒ mAAEY =@O~b@|EDW]U*ΣHHη~snɠ=Em@|)'ɇ WB:`5Lbm$ڠi-)/ |!LY:t_ud+ۯ~K-~?Fڃ4xI|y';}Xvi"͇:&4M7c%B}5bSeQ;ޜ;ΟB3l*і.Y;=zU 3ġ~: "hb]vŗ cp@PSgeuϸHw GI9C8uA)8{h+}\+pmy. VgdZ|=cZH~zO9IV/MwgNkt{vU,eO1fSmvMwZ06N'7!.3vT{_ҷɯy=Dqv֧|8reZPۋ+WS%?{l85=_bT :eӻHslU\MV̯ȥȗ$i>&I!FZπ%,@3i~G?}Qvys #4Tv/%MpFfH:E.W^qLߗx|GH7S^~t1!c\p,֕FQD6Y+9Fi[]K_4lqGrjdI4E'ECt?Q8 V34wf=0Mvt&H( f;cuhbL(~7,nX/' oJ^{W=DX,vBbR:XaWap!=N'xSٳ&*|g?e`Q!{T"v;7w" ǝRP6'D P3J,2V+b- QNgr>}" ;Pm̗}*B_~[Ii+ wvAy} Bu^Ŭ[p>kb8ʫSE}"}<_v'"iQ e&;qyNpQer7*8EJ`7ܴiLz/ٙҼzvŕWN.rDu*[`QcfX 3OGeKh! :Lj>@sBW׊mi͘ pLp|ʆ&]}5)Wݔu0j@vv{-viiBސ49P;%m^%Fse!8;BRGv)q4sѩtFK>Pp)8! pbUۊɻ ) jIW)2SoQ!iBHM< x?m4wuݞzꩧw;\5piR`/OQ:1hp8ĮsL"TM/믿Q߾>Ӗ-["'7eҲfР)8RJNbѠݘ֨@IP'v>{y^e?G]s)+>(BSx"BFSlW^~ŵ_XٴNӦ/w6ɧ)iVp (c\X_8XlU"ҷe&D_nyƎ;P,p1w>=?U qAz&1g޻2HIܚa9N9rţ?ҡ1Gn e՚97L<`_zAJ!^ۅ\2Y0o()w7{/ұ+]cI&%{W(E@;}u\0Ùsww}ZmMI . /E K^BkgGx*xU)^VtʕqO. ǟ@+~%`)“0ugO8Fp 㢦2w}ws>/r1?4gqFFJl "h&"`2F#7% j0ޮ#{ ם9G|IaMe.ꫯ`K xܚ 7J4^!~Jڹ; #*p.ZnjiVy-hՒ~' 6;mboͫ<(L/7Krp OU.큐ᗉLXMK@y;Қ.ҥesU҆5jԮMk,,Rras9+Ml2t s VY? Y6ͯjU5\8z@;wSA r-N=`ܗU߾}Cdee5}抱]cosOGntk\1Ǟ4 {Ĉa?I{{?7Q4~U yi| %c0Eޡ撺2Q/ޮTՑ6 OIsXc9ˇXX&Zr8w>D4NsaǞJO;׭%c>>yK{uj^Fm>5.!snzqBʰKi} zVÕAV&S_?*@ )$հ k??IW^u-2{k{W:<b_x Zs@,Z;:p~VSXL98bY̚5#5X6]/Ơ;-Z ngI%x.ɦ۪ r!Kz%PQ!YL5J`y2 7qŌTx86_uzhQdob4dА 6.2=̳)'7;=O@h/&c3*)7ް$Sxˉa[饭(k8ky̎!_ E3'*- ۼ"%(lZ#Ny1-a+p?&a|V)Y /%[-gnцdZٽO̔t`&|Mhc^61S^ʃ,3pEy S̛g|:XU.Jg?;?{:4˞APT(6s7FrV:.Cj@axϮJRؚ7o%l߯nذ! B*YXȜ9K{ : W SM1\{u0tu% ,╗_EcA} @r?v`9fe s ߹єb-|ptD*jyCq ᪃z?K!lzʬeA}SW(Hjx1WT@\!jvja <tGN:a\t;Ӎ)ŗ(+4ML<(J2+%_OEX5:q Lk8 ?U~eHNR2 Φ V4Ι+RnΨ(wlĮLAYGqO3f#GBk(xj_RXX[Qd\4S@G KW f_2nhS Z8N$CU3Gw2aV yn7Va`"渁*pUΝYA3:lz_woLU+P f,(}r5)g0U(acJe/#m߂AuZ)it|w ِl2tRťe7VaxM M׫.%ƤơnJ8ˋ-NM*^w!VI4 %Ob5!RY@6$H_Uhޓ NwU٬sap",$y'sgg?" 2#+*]]rcnݻƳ>pp荤[Nqf\odC(A+.JSP֮A`o]w/}(\*,^<<<:YV5H,b& )dH\H 7 aA#5ld:L%*{t9'VW6$Rѥ1~C3h\WTޅPG4ڱS9Kz*ؾs*TXB͊j>f\X *mݜЊ!Ǝ1އbt@tu*A 5v0a5aFڧzWV Ueu2E+^gx; U {Yz ]j~\eRu#,S?Yb#QԶM)fʹXJ-C8-Wvm#*bكS}w|Ͽ,H)+[8ңۍP kRsKұ8oʟʄh+Ԋu/u7Sx~-}G5FPb$6EێSg|=7mtYgӲe< U]XFƞjU*k^'aUk8fz / c*m)IWQ9x>uH .qWԚHǾ/zuldQ5n\?L}I͖y8e)ZNڇ @t\pE9F?| P K7Aar=!#NAdsﱸ{#NQX[o-=3/%e.PT>Qæ?aP.x: YqD\r>dRlF2ϊ=`ٽ:RȬ.Mxf5L2x(ye8VG:#l7 I$綝7Zq{ S:Ó4~-Va# `UG+ b[QL;OAw<>K9}sT4˖O˯Nj[ 4h@yoޟJ*@(otbH/Jz7ʰr:dXit1iaQώ{,d2VfK2c8 NP7/H^cr.IFK.;,JcPƿ{La6d['  P񹑙YMƦj@$[nݼU{ᇱk4[)!ua,[0 R##}3{Kb0 M:w| D<;~-g_M ʸZ9)6D_} qS~iؐ>iSswhDwC< hɆ0<]:wL]{2t!l셸}v)Q 84 җLH.5!isnckMF+Wd "]#KZN*J'=}hAv 5|-؃VV eS\}C-T L_\U'6ki (QX~3e%ǐJ=;#G 䫯Lwy_w /tҩS2Ït?g0qx ]t:9|ґIzm hS¸CR$V{1Qb:ȣW_|DYLrlqeá5gkΡɊkV5s+[Tw@ >SÆtAzl#q="{:*$Eܧyb<{9oM=U%%%AZы;YЄԼl.?MRr!(se_ jo9QO? ʑRP+1#E .vԙ-!# Se$2nBqB8$X2Q9^|u #d$Bt1cȄ :8(m_8N?CF=]0(A3;';j2M:}&s^4=YJ[lF._~u9xpx鏤o_y.aOn/VPU{C< Y4/+qr؅S@q']v@;h$mh7S iۧՑY/G]qcp sgƌZPPqV$Hةª,n')QR3~N>X/YLċP*I1Qd(0s^EIi1G{T**Gt^ 20D.gKꯐ}9N;6I^zՕ. ^_&S`l଀x v6s-UP*=]E&(HQ`Iz%0CaMAaEhu}Ȼt]vAy?OmPI(;oc>s:{&u65lݶcVGpTctZEw^_5ckfpKٓb+L*+U/ s3̳Xd '+ G| I᫯ !2xڇ0l KKwմ֭ `zht;>w3Sy(xGpxXZcƤZ*X^vNvFmvpq(qc̷Zz:P(B n(L4o[}I.mZS<뤉Bjk]z~BB,f%W_&/_d7B4oH1T1v]KOdeI'bZۊXU7K(wpa@ b`1 n%0CL#80-m[*.ԩ.S~1NOgM3 <jqS-[5Sbԕ*, Sm,ʩ;99v),)͛3/XsZǧͲPĻBhHs̝peyzJrNFˉ{y0>}zȄg>G. 0mxD dV-z. }N4)pgV:ޔfg# kTQ®2-\QxYY[ۣU-Zmr hOR|xFуZJIZxЍ%SFX(X\'lL~\<(1Vnժwf'/??  K9yyȣFDފC![c8Y"%ԲOSg 6 !z eWP oIQց~#FP? G\ZBB{ْWg*Duq^ր0k۵k{pn){>P:sz(K:Ɲ~rEsLre]_1 2s{чf5*.xðvbͻW^ Pud[#L{<¹&]+ޣ{ /t%gλz6NyM}59=ӛqwRʲe[@T OʺT/s..ֿ\eQ}޷cs 9SWsٲ -ws G! =[{jIs7(a+yJs3ߗrZ TD뚫V 4 N+`r6 0=bO3^}ȃ熠+FDU- GIZFv#Gfn0=#.k'O:50u {ꙧQp!קwnj;8[OQ.Gk6mic\y/DUj@[nąb%'FfZ*Y] d+N`5aͻQNxf׌uHia0/Dްg*M=q\ KO8r[ 3Qf$0ЫW,ơ!DԐv]裼 9k#&R%I ^yT,زriԟGvւ[h̪ճ4:b.+jy?iz{M⚬?Oa"D~:CH}ejߦ] \-SӋJTAa# Rnul"x]Bׯ]?\‚塭ׂ >=wZy7mt~X_pA 5}N]=OK \-MWBcOlcf-K퐽2bƷ(}]-,ӟ{2_Խb SNNnj59kI FL;g^Ǽ:a#ՍTpe6+x?eKx\;S՞h5 XDZ&څ Wʒim]}Pz(>a83"Akr6o,fNgQSqb]bʣHۆXQzI ?ms;&+Ys 5w!8i#Lӛ Z ܳzE.UB9&EWv-PiY2$2;;72޻oԫOo~ǔ"sΏJb8F 9 f*"Bs6܍)`8)8|+sʚ#ntFYtg<{9KWc\j:΅x"z5t]pf /9w(]aZs({̆Ԁ0>e@8K8۲m tٔ‚?x>\>8=(m}jlƽAU.V6M"T/Dܡժbxq޻QYkm!RpɼT&Ufu^q˼Ѣ;, 3xJ)?.L/Ԟu4p݃6mrH7|^ma+C ԪOVum)|>7r?J kN=HQܹs|3Qk?ǩ4EAF&sB]m=|oTH~B{B61!{Tv][W07N tlev@_K/TԹ{7@#"tB ίXldIp_aUTRZd+3egg K.M%;vg6] zUP1{"u7t\=!4 NPb'l^ުE0<F!$xsE;#i.)<=VmQq0,ߙ1ƛzXK-n9QȂ 0ӼHn0k+bn{Q-/œGރɬ. KwjĿe(Dn.t@֕󹒀2m$9ˮ<Po䣕׿?`SNx}H0ܖʫ\tȿX$-H?PP'~#d_|3t۶1E{tDd*.ZI_@E~=^ݢs%oӦ5B=!a$g+u/_}%=i :}'M%<׮Yx}$l}(1uo`JYq%%yfi_֖Jz wYZZ2z ҀT*ٿ)%qg{6+cA͏=zZxotdXjlr4%ݺu mn=7WAJXe6CQ+Vyco=tۓ"B;iAމ (fJ¨S7׫ VѺKdLWdKn@)Ց~6| I吹Pklnޞn&img__͠l#gX$Mh]@!^P*\ ~sV;pCG`}6"_*= LcoK,26J N g:Fh3d^c99zp *sdUG.XRH&6f˗߰!KAD9rӋ/1 u& _I9=O/0jAsHH u Bu+v*pL-mgmVDVjx֥KLn`K/ ͺfu>êϊT7+/ e8nHX%nXh~dҰZq=j]y-X_n5DXgC!O:e\:ɱM4ritЁ/ %@2Tbƺ_}uU{VK '{&iΈ8-ڈ_ /âd^Qc&5X"v)"XPU"n~( a}.qSU{C0xC2(4ܹ 22~^#OsgϊDq%l%綆,A|4>81,T|9xIAOkm,FG^wVw@ jT[n<.b e?s3\?)Nۡ?%]ҔCp '5+{7+^tTMg\^`TѣOٶ!EOԽ@4` T <ˊEG1>m`Rcyh߾JHytW sk:Vg<s 44a”4H˞y$&ڈ܎rK+֭STG %;ҕ\ n:u 6Q0 _+G!e4!dŜ-it~%Di"[l@(䱨ΤG] A ZB,< Ӗ]hkAr颁76&xu[ohkpi٤9aĴ05m&&Lu%ge5ˀScʿ^|1:s]T `9Gѝ(,X><1F}*m򠳶r²C -717߫>oO?&XqOu<c._ ͘iLjЭ[ Ii%-[Ӛ> y:֛xu`Yyu]s0c,jŸjX`{)Zom J6}9q:U^@)=bKMV;DÇcz̻Ά~w /F6Ut~?۱c[.WY5ޫ9SePs2Y#n;Z汇*5` W"Z YFyïyii1'>9QSSjuP4)h4^pŗebgA iJ9U8dme*be|ULe^rs l ~W>$%]XΝ;t.jA`@,j*h h^+εn]9 !5dM܉Ϸ~ϋ/V)U(r1!rn1v^=ؤ_!?e~**7=# \q޿m1KQϊ8 LJߵQ6Lu2TzmXoRl"\z Wa-õ)`UTRX+huOނiAӏg^A{b;Am22TO<e亂n)̟2 Ӗn V%; OCM"ںZR[d? ś찦ܩ=^?`Qh!/MOԇpca=ʧoyzIސ.䒔nz 60m$ O?~Gfm^h o{U0\Cfsǝ Hk5"&&i)ɢX,*ZeKkp[i+p4=)qo\XƜ 6  KY`\y!tmNRi`iӆWfG=СC-7?{lrEOFXvAvpnv6ʁ `ɥrKұKX.tk e6|hzw#MP-C@n/j_Dij>(.7b Z,_$iUi_j,f|snb^#GBM R'0F!1uM7m9t/F췦+Q!xmi H&5C44iJ( /qM`TB{)b:Z7Y@p}i.^XvvNfԀseE8+c\ׅ;oHhؒ0v،_BaȐS!  Q_'@!+jD/Yҩk`8P9Hw8L~4|[8MZիWƯMڭK'fC@Y1VkXak:sb1+!e(HZr3,=/rޟte '4euBjSÂMh#Er !L]T9'&|6,4;o/r lgUiB^/ ):3"~? {w:c ,vA/a-2#k!ȕW^e+^Y*$7Zݵى K~pj2^qɿyM=b+V)6~1}߫fzImQ-9 P8r̠^:3_rj9ъ^VDm믾A){oRvME+N{Kްa VJ!4l\[ :ʢ&X%Ix\b7GmOoMjaCIhM䛴$CI8T~ĩπ9*M'LWBz~F] 'a.Tnڸ^Uc)k|'+ơ*!4B{?9c2`%۫YScow3}BWK\eKҷʂؓ}$u >_w(X+Ȝ|j%fG¬Cmbj9 2Xg_AaGb zNʾU42jlvZ8F<1cFimƋs@BK^ԾS9tA!@(i13E@O ߥaC}03S=cҩc<ߟ|?_k]fCaMI-DT9y#Y(m\iy6ГՊWeM,Z8ghJד]Zu.T]u]4۸km|S^U?ԓiḀ=ӷ_}Bثl޼k rtMKثT;Jm;Ԛn꾽Ԥ%u,xpua^Dۊ QwdcszF !X9[Or@O=ץ ;ZN;x4Len HW5X d*۸oك+P 83+7J:iIhMc(UW]sh 5l!LAe| 1<ܺ G"v(Z *I;gUxw~Gz𡇃3v1-Ri(x4yq)ZaEu؎pvzOS!L:=a!LsS&Dy֝٫sa?ބ5 r[ݟQ#Fuvi_)-ZoԹk'i@݅! :8&2TjhTIź̰a Tk;U) XL-G}^0E*XaC-~>Sq+ywi)LcXZ؋s bH Z1t; .`J@fuЄg. +Lrh!ksE{F ;(W؄ŤW!cm遝0NA~L*d\lYtYtBW7Ih=U5'OLW\v)B1jE8\'3ii&65L#0-=+#ӜG}]VM:ܻ- /쭧%[Q y_,ƒ-Mvr(sκ 䦣> &n1}I X\ٛ  EfRcGw  =#[MyaIhb纛/ܹ gpW^}1 6"hK, e*ee[[F|֍)5k…7Zm_FC1LyޤX ![JؗKVKdԹK7wT^x|ByWN>PnPdjV /{v͵W7_waMnzWJW9ms{Oh7֖t=ds :,ZL /L@OBM ̓#ıAOFH=z,+|wͣX,6SX{né_EN_jJjCPɇ*̛zH_?E;SO8aeW\d'Xڵ؞Hmq.Kg x7~;\`[kO}/ @/ŕܹs:}M?hլ;e=(X1hcKw_:]xquZl[6 vezKvg6L,KϿB`{_S&3k ,aO :l ʞ;=؄aD MnYx5iܩ0 Z1`{=~4<;tɬv= l,ʬY AOuD1eVio泳7*ۯ2®3}'2>?:Zs=L2o @ēAb3| X̅Qleeᢅ4<8'{?/",\^;ʯRR%ziVL;gޯ\}i6x1:ē`$^wB"K%AڭszӿWx83__D/t`א7Ĥw1./eDs3ALoj!, :@9Zh :Vk֖,@$Cψzz LM*Y3;g7s 쌲l]Y^IP \^y1Zk;JY8}z\uOZ}@W@ѭP^C;-c_ktn]ŔU>>bx&-7i4 fBdw<Ol[N ­{\âM(8g"F>f(ͳZ@\Fa;iIwoHLy҉p(Ci׮ «Lī*h"bqFQ5 )J $U@ӧս\ʚfxY';1. 8l.]h@eTѴX'%=ʷ[Tު]9 Xs.U0agr# <7 8ŚD+Yj4\MG^Hw`K:IY071'cK704>Iro#M75LwЕ&7<ߵ;@|4 fUW^KOӏ\JK9ف,+! zhk}h.ҧ[ɧ-fc[ll>FocO36ч:x7ّNdMe݇8[ׇA V0SN^VcX =CC}$CЁC!H//ʹU}!^MYVXAAj6\= HZb ,:MYdvECV3'l*hG'X7{k@?+CzQ^E_hގ{lP _Z}tӀAXOpri aJ#[@R \Fz06'}0a2_)B ZoN(z?|19u)R8lF:B*TKp7_#R>Ap|5衴lA\[#}<w}gjݡ]uw--o3JeEi: MK3#}x~-Xr25\#ūA}Ăqr4, k|0`eʬX ekxt> j*?,&i6gQQ3lfhj6SR *@B~/[qm2uҟKo\=61 ֓ҙgC(&Lgn)ּ̹cBȨm̸CMjL kI yy׺up M9XN8#qڹ(@e_.]:DQVf>X#EE.Mӯ\@KCIi&h8 :7>3*Os񱎦m.f"wA@W*Lk?)ÖMRrz!%!7AnIU>- R׻7aŖ8;7Ѽڡ>ʧ4#5l0Uv-v~ʈ,*, G:c\y^{x+c2"d-@ڢ5dSȆ$yyTN&OeNZyރW>гЌaxeC ǽ짙?+ *kQ5*U2ۚڻM|qkvPkh6tSXDmex$Bc-A{GC%(|2Ug +V~Zt}﫮 @W(+C:u) /߻ˣ%R?H c_ت!K7 o~Wn;GJ lJ8g_Q,vҋ/E\2;@KB nn<޲ sho @'V>fըuw h\7BGxӏx8^9Q2fP"]W0|-FVb+SƺW D={}ЖKWr?C=vQk-A 4}4?`Sq5)ue7T*?D+^ǟ(cR)V/ZP@|VB?Ektoin4G7p @ߗFŦMQu&1sA|`af-%Q8լYNxHZ kTN'EQJ8nY䧟~O\܇҅_ϧsU^IHz᪛CW ~˜&QP6ޖ6n(88 TЬa 7VDRJ!Æy(i)Xk߈vYmuB*ҍS\xfwF['qArↁ!-:ԨǪVF0l˴z)kZ&M)pTcpb4|OY n:u;4rԑiذy6(eCއVJ9֫k9vʘ`3S`5-#o>|3"bCh]ΞmwF!x 2sqcRȽB%jK_\h4 _DPYs%l, [ .jhgpB2fQaq()J4 ֵZUKnZݼ65D{EsLi7u̱0w'ECCRo`-$wGa+G14 :4wqXތ,M:{K5Qƒ3/!rhM6Q-"j:Ze;#ubś *j]m( g(`>.`A" 3bDFHhG J^w_ezi\}ڔ]XPr'^ L>eޮ=Ɠ;9ĺ]w RSS&ļK TPHn:YZܵsWV ȦGutLQ/BHLXDŽf#{SH^zaz7IK/JN#ut#cE3=6o*j } v Do=q ahۼg-X 2bjA +h{:p\Dm6mt{us?`DMr5`;2ͧ!@qݏxg-aHVIH1X{e(l"҂2ϗnW/{]cua`0W(gxI~ڱ s֝@3x%kvRz s濮KEs;\D{2 _Pbu~%f糰?Ϭ#jcV6 aEEwIˢUzIjqhpwvT A}v#5yZA9Z;hiʏBj ю]pIB{p|nSz֟Wjzٺڂs`;2&7^U֓/z  GmѫvzqHLA(]p=%^EbaeѪa*xX&b}Ǝ=>B\D%/Yju*rm^A; t%SAvHUOY/S>PV1m }~uQ)ad=SCq{bjl~ 0\%~6TgYE10ճWR;,;ԃw#HHA:^9s,XZY=AàU ͘1pVS|G _c>@JK~ZQ*΀acSPs GUs-Fe11Fhy x#LmT,g% ܿ5lq~?/q9_˖n0#FtLSMr=Js,< ^B:s _'CKiuVw ̥)Kۿ7)\pc Ptgw6@<uD)UVFw5-dĸ>q5XDߤt^}Օ^yԷWwLmBJ2hXpE2uTf[fJD6tL49(5jyj`*ޒs*CXwQ!9*"w{,TZuܙ{}+ٰ OMPdk`y&[ ؜|b1z C8q ?CW#PMmOյ7[&6・dC(cKe}-q9W"P=j)Y: 8H*=!Qo I2Mg ' _*jyO 5Cύ*"تbqtgwZ@d1`)MSVe"a'Z)hA{K)?\;mƏHV'<:`t]Þ=dx ;c7>.N'6li؀j%R=Tn˼>$˞q^[pr[Srus4of߾6n?-X.M-qɮ(XKn7LEj*nr'jE #I9T[5屮!Ip;#]zو0ƹc!ppmq ܤrXC_[J2ȶ>蜚$^SIx ss2=pmP\KF;NK Ͱcߡ tT bضeE;qU1H]Vpڳr(G乭 ڵ x:8l|SkF|o ^x] p}LEj1g^r r鮻sW\Tc^kAd>OgEatͱVyM,PuUgr\$7I$۸7\0mz/!@I|0If!d&$c&4R!tLEn-˒\*udrwdk-fVD J,Lגafn\Tws.D?X Ö[7ccV\,u{Mg-?1ͻ _\L5אNmLiILІ- h K&4h1[YN+@z4ln0 F2ءk_F!rͦ|?|۳| `mE!MtjCJuT Y6)_T%-u$.pk6Z̚Fhgd4@t;&1t' ( 7u'1Ք?yu%:~@5`}xkM3--eCyb}Ю~`lM@]R8tIƧBrJMohL74+z9oDpE iL"59|z3YPf1I=ݕN@eVcއ#&:G0Oay蒴=Ϧ_3y~īj"KvY9R5S*ȇ1c1W8zvJCk෮`RYYYF1:aQҍ <[ :xz< h @ ]$Z4lعDae &/_\>!dM NS3˪ z8I@bqGBDs~C8U۹JPQ],\uX!!EAKl4  ?4<]pY= p0KS+{fvϞ5DB|E]_>@lWde + Z&PO%^T&BdI+ hԜ,`4@wZwflv/N4GS\?LpsON)蔘k9^'_vH1s?b7|,L ؒ!5p;,k=ZkQֶW;.zk{(AKh2[-Uj㦜e j{q6nrG }@ V%VU9}+#.4as!kˠo]MѪ4Ty;=ƌ91Ir{M| 4(|3&?ZU e19|O{G9~-;%+)]^ޙ&6n4WyDz(&vE3MD3E Ԃ=׳od DDҔ0elp 7t^#Hcv#PGBM:k: +:k=2H!4i.4y$h y+f̜MO8%чC2bf}"7]HPmӭ_%'e˶t-)f9Sqf 9GZV㡶TDT8wٵ$rB+${m"+Y[#:a*4FE&>!D `8&1X/h&q*$KɓO-;o@QΪuf-^ܴn똃fIucv~ߦߟ4%՟f *XNr8qBgR/ϕrG_LoUYh& !e|gеоsF8 }a-AܮFmPִ J5{Ė~{PdLn{ TeMNV̇jZgd=j={Z7]ctekkit_fIDK创jk=1M: $LށC~7G}GTElgAfR-ptN,# ShZuvaH,8ɯ`vkD{1<4DލO8=&oRM'YtA_LecϘ56oYJ7W+Ӏ򈭏9(TIG 'dㆍbYN9fBӰT014-`#no\%G03S7\ZJœc\՛!ך9%Y`C+0pfx0L:a]0n "Zp ٓm@)TžQ$]#CCu)#7C+fp(|6ٖZ3voJQX%)6﷊5T,;vns ?b=AC8%^DwG6[DnjՄWi IɁTH'ɥ}^j۞7^}']@R{Y$tgJkE`rLapJ-~fnuj/~ TRqa Uqqߗ5ѩI8:qjoMFC@%475snV,!SzlDuXĔkAėqz6]P8 0 8RAh鏀>(9f55&i@ u뢺ʝLl]UUX*=cY1}f| ,xf3ʆk/&FO3tqy]j( (,kh9aReԇn2`}{ڕ墺;nH#6o -qJD mOwc ($}swdr'<@m.@t*"K+*bGO`)]\U ^{Iteh?P} B⪸8ZYrhҭE5(.)JϿcYyFcS`5%;zo| e84f$* LYUaj$a }nLNwWnƘ5WO7ed\i ^SwH7}{a Y5:;z[HQA22EрBOeB` #9ghU\A߅l3+6'Yam]ЭUS InO ;J!ٺ0*a[i]fU*Ь9!+vS S|Hc* ~n}{YLep7u*BxRpԽ;\]2Q%ۗI) 0F p$0Jd_K%Q9dRO|x=;͏5Zj15T(۷PگoiHXh|vA*J*mmO Vedu=bc^}Rvc9nolx+~n-u $&_Tr(4vsRae>~6k"OJo`AK@N3s2VL-kM%~o*=n1‘ŘZ=wfz>H٘6Ͷ@12JkDIwP0䷾[;@8* !f9CtGn&}2]z"`zu;;6MX`VmcDDPi.N,{42iŪ ~S/o clkvlhGh4]A&bVMţ_v9\膆Ma6>&?Q jgOy\nQخJBmX$RXyP^ .%JϘ60n6Α߱v]ˤkW6؀f!c*+40C9L )<VHL|gy!Z2[Fy;>./փ>鰙$Lz2}nݼ!\Cl̶ֵXd\W-mҭRcX5s!嘰ԓشDbݽZz#_ѣFblOO=*ov;ﰦb Z:݃-؎PgsVES~)҂{m(0݃>cniϘ}KK)ƣ*(}YH7!> ^s@Dkޜ0B`l3[iff֟Hq;";l7= .&]]L)kB]x"H4o55BR2IqfEv8z] H}orJF0F!DJ Z[@;dA͋ `{XH3[Y3491TM-~≣(D7l6/dE Ɛ)=o4VкdRa~z$3jDzc:GAu_<>xL&l%Ke!ȼ`}z>{4[vaE4mfR':1۟ Q-AB8 8cGD5DS?WQw+8%ٝ*87BrЕ,ssp~`_|$w TsoRFA$ I\Ikʪ~iGajo!aխaZ{L3W60w~&`v3!Fyw:rZ@΋);u6#V "V\ߗxM6MüwXOu5Fpjĺ6q2k5bGz49bCUFyW՗| s$5TK ʼ2#Y6&[;X$YSźg!<vd~m͚50<+tLP#',_*,"m6JkgiP¼*m߹7!i(y+Όs09en)j@viO̊3ڧHlcS 0܇LAOiA(4|9uA'2U|kEoNc,qP/U2J|Cn$(Ŭ3Q]$-ɦI)Y j2[ '3 (eqC);s桡 cHmJ~ҿI7r3G0Niӊt ٌ6/ WZb}H}LMY"ZF{6]Ȩ`MhvyLV7(.*J>VECĜGĞ;?4˿m[yxjfn'r0r4jum? v ڌp:hVkݘ|_qn]cWhXXE;~b4Q6}z] bl:ఆ$-K2l>i!za#m"4'AS%sH; } 'xhq97ܶдg+BLNˇ()<.d1}d(Q5IHP@ci}fmI(|ϟ8Bք={5|c  &W:239_Ĥ #84kj8Sud[qϽ@Dm4ѣBЦFҷM(w7 zĒAT5jH#gZEܾO2*f HҩG4< =Z=şdwL<#l bh8zt/d54<:"=(NdJf&tmI?7E(S~&M(_ %LGgkL"mZ廬N`MTDX$t5|5P 1H{X4`̛0gFw ᶝݳ0Ehc J+Lևz';>~`/vM,m^xe,ZTO=k Jly/`Dz{Le׹вF"r@fJ|ƍCsDa _E{ v, 8{`Gvk$C:nԧI3"sy&~Zxh)1wh@G4-Y.:@2 i5>3t7|c`Hwu{Æ]CxL\>r, w̳N='' ,3.QQRGē5|wHW(gQg*= @0G8?XߓMZݸ2B~5uYK1+Dvz coJTumk ̙3p&GQnKq]?rG{NwS*x!s֧ڰ@ 2vPHWF `k @;p[3ޒ Wc֗}7~b_xŁ~.Y4vDo_:uj3 <4aCFKRióEiՌ7Pj%-35Ńmhu@ !wC&0mAd`6p|/5$oVT%%eHD|D=G0#_JtAd ~[ݷ -KK7}P7yX n`hֺ|,ńI,c}a;X>{ }҈? J⋱I_-۷ZԻF:eTF/•ED(ygҦu48=,BЎB6S) F]0psT}6 (xt=A8HNNb\b-݂'6LxN3QBb*!$_\կs%;Kjwe$W䣻-0Y7D|=vjSF@Cp2(~F>#khȆ &vM1vZ1Y2{\Q2>r۷5;a:Z S4upHg4Dcڧ3LG뤻b:SmPᘇR.w_I|42ry@ЈiNH_/YuMW;-rK9qeeUX9?}Ab`բ)/`mc„QWEc8-*DkeVtLk3MđO7}ıw'q_<|FwyjԄJVk~{л$pusʛHH@9\-Z+Cs|dVv{םAyyzog_KxABf,]>05 1|}A=uŤΒHd akj!Sf!c,YB@ VLe%W^FkRg#+2i4 @8 2Vҥ6t"5 0$UUͭ&[م%b$[lov+{ +mwżyxZz=/7`'J[cjƱp2jla?.l#B$Z[!JKy]*VWV~GckPr`S"^Z ͻNXd-{wM~ϼ TdN,J1(Z֯< C)iC)~4F  iQ;h_9/ &0OpjztExXMp".) s I=EqHܬo-) [/t3)h< q97+{ f`m\\N;(>Z._|>}qH笂K1_L?y!m%Zҷ(UΦg N;C%B0 i ~2h.M9j8w}ml y/[vVfjVK.M'vI㣁mE̾z'BEhJd2֍%]ͬ3SS}nMm9y@ [-?TZ;aɁSjnͲRcX=*}7 -lJ_Nk|4Ɇ Pxe>E)Y e`^x9\^hZ;Xν{tI%U|5eBV룏?JW)[п@e k@ 1>@yE ޟVc? Z;u(ut˝غ j^&JX(E$tq(i82k?EIII*++qׯ@Bji~j90L+kf K_2ĥFoΠ= [:Ai/3Ӟ 9*&C#˜T}041.s{Qe9F[]yH|Æ s=.0vn˟46mz5Mt }/}n C"otjcCS\&ZH rM%=m0H8oΩ{x F/^8gW /YbGO?>Gb o<ףx9wͻλH"a{MK}L,w4ҫ[Vƕ|?l,Hpbaatg?nVA>a XϚ55az.77}S:utԂ_b1X*]~!y0*3fBc*h+Vg* Y`@k.ܮI>cւwa)]}jR^^XEaW[{Qu=\2B8{ >7?JŦ.gR抍XF\]&_CsűeeǬ.=x Llx9b裏;` ™J?Cv/?{S_݅0j\c'Ǔ-7R D%ьV;)1:^RV㬯Q;V8o.1 m< []ҏlMmLf=z^GɪVT(|T~@{L~ \ji ;B cCny)]<>nd!,%܁Ϫ[Uб,>MBw֋.:zMhjM|1 'wlkϠ& tΛnt,t3tg*VU2\8Th}52I4_qgӗ>;swZ()Y.--:ĦOAzQ@2@N&14#E5|/ &VڵspuGLQG"pU=!-Ĵ!3  7Uj]mUaxQ#rvBa$0mwM$ɼIOL 4@Ť>|h(NL**=ch3oM ޾CTN30-aIMA o~'~v敕.t%;v̉螷^ojw ~g06fJmDtuq ~v3_&~k]f+MrÛ.}?Hit|,ѝ F di".橛,1& ۝.]_lߌ֛g0k=W?aРBZ"4V[JUSITP7XYZzyw2]pm a 2 k[+#{ڤt!Cwr܅aBwz7Rk BrZs؛ɠQikV*e@$5Z-QI;xC aT6ɢ"McR77ߎ;YL^-yy1kH43LT\!%M}Tyi̸SUWgZ@F@n[v}0ah9i=폸Ifr g%NI H;B0.g]O1lXCOP2wIW_{YnЃ of<@{1]~!-);OVMiM(<֮tA N(g]h:w 5]_Kt ]F8%!o-89{@81 f38h >FXBCj\Lr'A\G"RCB^g^OX,HǛ @FA⏹YjP3]tz%_ q$;96l$O)3-;[6 wotdo F?y9_!"~Y՟ ‰'_48?V4=̫tLl[[}H1Q#?1jI}I fZ#`qyhdRFѴ& 1עY siD<߼~N0ؿ7R Sɧi'{.%[z-d$Yɤ(M&.ZRNRZI)m#ZxhO`m[Zva?=n:;_4}LCd DӒU s ziY)]N\gN<55#@ bկt \*ZtGs$Hݜ,޽q7q|uqoWαt7"#rT) Fvfk̹Z=#ltm:u iӴkbރƥJUx[L Ȕv><3cn@TP- P360~K]]*U\}U㗁G)o׺{7ؕ :t%>Bhzʣz-CZS,7BICuT2kbsJ-U9^`$pMl~qu `tChc؅e[ 2Fx쌱|0 Dy#\T n:yǑLQHt91QGs3ܚ}bK)Aݵ-Vb,-9V#ݽfR%]kI5Vߕ[V]A禽Fď;.MF#<4<7/y{,QcGnEfhu@pꌜϫĬ֒u%\ _^kU:4=~dǝpE\Ml1}tr#Rd5jtfUqshZ*Xo}]݇LCɠ UZA伧< Mg w{,s]vEefz[ շo\׋(L< ̂[_zҗ;wtV;eeUSwaXyѬm(( ϟtpZ8Zp Ǝ _`Lh3ķ%?+`m;ocQN @Bq{ 攘al&dN00LnXdF ZMJ«\](}sDSZ%?irۭ_`O' Jh4UЎM򬏵ɤB 4cqCląv2R5'OSEG!uCQHVh~^ 2#g- Z ]k$\WG`JS40YImosl hRk{g±8%Z^w̆23s޽'Db0{QfFzB<)M/L$z>JȦP;lk: 0%iX[2N ]r1 {JW0|xLկ is 0%N#sRϟ;M(OIN>sZ!3ψJK{M[Χ óU?*Uys7nߵnb; }ė3> \pA6P#VWCX*8WW6Dh I͢'.m[>t!Tң!)Ukä7V'MVl^|=k;JB9pQmpISB+>V )ym:DV4?ՎJ`d-hF3޼ P2\=OuCAQP6l8+ӻs|@XloM0@_{~Zz_z"h֒02{˃lCi1|Q[UF_Tk~do1tuY9ew̙jl+{/̭=tL+0:n!92v56o9ǟ_ZJc٧wU cq k7Mh">q(y!CtkҺM5lU30kj6W,J)X) 'o*++ eZ\\C?mw|9ҍGa= aᜏvl6 ā`dv[eSիix> p66ß=.ޅ/Վo;qQ*Cӕ0MRV[j :İ0d,Wwm{1 ~&;vlݺkFL9=uApHs*@S`_aGRa}jacǍKƟvOs3aSɓݏ`k|MKL ȣ%2MYܴoGqCtSh>Zl@VޞN!5a暛fTKk Yc W=ͱ; PWSme4MqaF!/7NfUW'?n`f5. clK[n#w茙?> dL V-";Ƣ}۴!ym0X%x"5F`h/e pӖ~g5쐣pUҚ SZZ.?<9=8}4oN9zwd=Iu7\KO{yRqJéS ƌy H7}1};Mxݻ8ܜx]xw>;m k0.\iĿ]}E, =LB;흐ѫ$??YWWבgђ@?}nW=)`tt' pt;DJKt[CóƺCXq I%'$E.ƅ9 ~OO"!@SC.C\S] ,4].,%.:ovoƖf-w˒´ǝ:p>YTWAp@yVUTذy3th[c$ݎV6^<%oll`)rlt`ܘqmGz;ìWX61^C᣻a^wT\#1[Za 4-][ve¨*FϺ5~.^hvtL_$+==V^nVSX3-wO6o"X3P^Ժd"Œˎ02N3[H\Opqap7߀(aHH1pohpzµmבȊ )*juіa=O/B=%1yjz7R2UMCgdj.""Ju7ѳRׯ_, [~.4^bASPAMedu5鎦Z!7$zM#c@| 9BDSxxkV˫,;ylwJzÇZpߢQ qFUδ\z2ֆ31l'M$|8`T8ǼV8X1ʰhު5M xhZ|~,R,)MHR!K~U0ք{5>5j~x[n(O>ޞo=)sNŔd!̼vP8sUhew囩7p=@q XT=~:p#<" )4}L5c>M~Tdw32'mߺ83=+U}#_=>V1];5 \G]N B55ト6mܴ@kLs,=TPsz1(| HȓGszC\tE1k$^ձmSXJIp(m{>{v&={CdTO)n/BIjS/ * 5u KI&a81kdX_kfj8` FVCi&chڹO7&2;v,1X 8`4U-#j#CaC =Ґ"쥋r$ 3Ī5#}PÒ%50|ڷu;.oH/V`|;5k"OČLIԑyҲl`T%EG8f#^k>s<a&Éi:dbݣ2f|H2>QJaiMU^䎔XH~iؑxhܰl4oǝCZt4ߝ+* UUؾs W-KwߓRl"lDYjQYF1^*׭(X當|7MC܌3rd/TҳEݻaf/~%P r" asr";Ʀ áw{.[k%a>x\Z1覌 \[XV $7I6-}וW^^!i/{5τ{~>ΉBH蚃js En*'MMh-˞ՆD(fo&f=ZVm#Y3?id7jT0D_bvo3wp qYP#nXZZy]µ. u"MïȂw4O}k<ePuNL)v5B' H%@8)"a6- }{DhQN"׶>Zl$=qS[QF[^Om/:9as? ;mӨ.F3Rؿb) I_%eu@4Cm`%[: g~>-\2BtɁXSy%-ːǧs67uّf&猯1t\miQ'F3;&Chڸ]oT Lc2ع-J3ǵl)-E&\5o7B%?OWow,3rБD5o|A4~OkBD$#uBGj7"|zKNLG|Sm}*650V+$+T1Lp,#t4Z3X@PKLϢx( Mp2=.N!'2"C>N1s; =066B#iHDYb+b1Zl:-\-Wy7<ϧ;g482Ls/UDx{QR!IHـ pąd;M{tW4xfGCm9!K+C|dP_~0MRN(̡$读tš4K 룵dL)J M 6U" -)7; 44{bUj0 ~D'׮W})SжM#' IxM~6Ӎ:p8c"䟠SϿ4|MY|]:wV`%&Dtܼ(8 - ɤ[ sbX՛lZh6~̣k ƵAe$Fe4EIOCFGgEp;T#Fi9eL.:h푳>e;] ?K+qSf f&> 0>ְaoͤU`_P&fiydt k%5uVPN;0]dź!aVQ< $urg$ZhБUi< C?} }:n(Mfk3^Gqvm;"t;.=ZQ0 }㰖 ?\U*41L,m:a;F+OhCttY~SȪ%}JCohk.7|+c e12hm2L6kŋ8kEevPw56{HP]ӳy/ɥOc`3i_^&xLrZֆv啱s+$e(2z%{ªG|p' &HJW7=64fS [{t0>1!݁1ƓPAX{,7[zoJFn߲R'{.3 Υ͉Nkh%/V1/Zlj +޽p} mj$D&駄S`ǿ%=haBZ 3-t=f{v[]b%=zҋ/C h`b/`-JYȭDN8_Xl6Fѣ"DBE9wgNƗ5?hN]·`E0C8ϧ-{߹352N̗pUshHLZc'jv]Skٺ9G߰Ⱥ f]5ہyJ͚JUXq泾? c`ZI]C!uH>wDx9|Gg럽A0/΋qOxsǻ|qLۈ{YAsBUG71G-\ Of֢F=POvWZ󼥂QVyKʈaMbi4߳YE' {Uԝܒ+ cH 9恅N>p+A5͈wgӌ6\q֐F`WZZ٭oZ"v/1}CI6%nAaL2qgίqjQCD*3+D$qZUV+/+cGÞ{ʺ1޹3ˀk\YgfUyڌiEE}"kŕ@ =|0 9[m j׷z;L:%Nڡ]+Ԯ0` c %dxҜM l8#bL$x|L6|[ |u4|n PAb0Ԥ0aPŸ4FF\;&k 'UŴ5AM=M$| ;y/axsyӋf(N򷿝G\)F}tnItgJMf:5qۤ9P,L/>KF*5  +F'ҵPz]WusL6ƝW3BV # zKSݳL$8+M3aoݝNeLWȾ؆ף B"cr $&+w^֞ 0= H j3)q)QId 2J M<#o-f=$,_ 0):l{_ iLbj.XD˜L#eH&Bd >n;{6X~ZC7Rm&*Ȣ8+{eqs?t^\BM_z*lM=Ghd4g3 wAd}P>cFۢ%k)Ap^fpQ6%(P+nn!CpK&6' zS/ֈIEf*A-bZ7mLy'Ui#O ˌy6lh[,')xћ&7$,.VЌlﳥh^7|?` #5fUTTES3mCКv鐱ܤHcZB&24a *gӬM`IX=h?[F_1=ye066Pd~nD~;7n%h)\bfǵڛ6 7c\"k9jrMpxL#z`mb 38dx1ILsZzLA'F.bק~.fQ3Ow˭15+8kfV]BQ o*,ksy˵D0fsI*8za3KgeeN/[hl(G3Pj CEs&hK$ 5LD,\W-jҩY#L\<5:x ZOe/I۷]gzI?,樠1%/1ݘ)#>^V 1-ctO2=]o+o` 2JvǢ2&ȖRƢ[jc}#kR> Zcֆ; @#&bOf\K&J>S+9HX%wL=$BR\jxs88yL{CZ|>{-j/6”fC^v$&ŎO9~Ϙ1eO0cP4|~s'O7pscn}CDn+0RchnGV:fYjkĵ-aEHC\^}Ļm]b &Eԇ1 ˮ :vQ97Leּ3U{ !x 9f+0'bhIZWT0JB^;[ߞZ } 1g/!/}!9ޠ{>vbNWHȐ$p݃[6$dC-{u#7p7m,lZoH⾹GX [̕Plbkn7sG^9gζs+sΘ,-α.&Qƞq=E! D䚹rnƏ}cG}T1iqr,1-0Bg.ٳ@y]eu&0m:| YMuۦo_Z)+-M̛l`P7~,)@>k"Nˣ twF"\3@ժZYYiĽepkBJKbM[O7G:\΀}ETR5F.\{ڴ!CܬA@m"3-k\0Zs)k͛ rA#}.3 nkvz)QcE$2'Af5̦TK>LVFk9gz9XF 9SG1ywLc m߾co%|`4G"\vxɚqXAQX]S> F.#gogf%X<[E >q&CFj/q=A1B-=>QeBu&F̕pQF$~t/|_W9U \O)odB\Ek;~Aǖa0qy&t#"w`)m/H5ysϒ{|dξYkddvޑiA)/Bu U, ڳʫte˯ `{t w\ahڹs`w *-+r=Y >`XshPnM ZLH l9udz1l&kmhv":Ӣ38#Fw{/x1g1kM;{B 1io`\jՔZM a6 EZ"*p 5Mva2XPFƐ2e~V"gj1/<6jջ+DZڽj!TM't-\0Ɗ,sv<Ԑ2㕙dVGhcF#y-_kֆؖX+Ͻfvx?us?7 D&Etn!{1h^;n:fA-49[^M=6aIwe <{udA"0F$z.ٌ& @aYJ:6 \Ta91ܗCgg#w OPFgtַOq 9Ku7^ zu5ϝ#B١C'ށQufŎ6,34zWFru[@ݱXhuFB~ 24 @Z2ֹ_2;)JQ$ay\{b/Әl3OG!L*,_byG64y6b 65 p ME-UO=+WgFs62cifkh`_4T9HD $W9_53 DDVu6ZNi\{?Ohl}yW?1'ck"`Foۋļw2u3ʝtcz!*㺖N66!&0F\AAnsk0?c3X$j%b [!-)0woڟﰰ1.ov v^`us΍Q/[^ݙ;д^r)9&s3r\&>4e&Zƺmi!C>hl"f/ACl504z>(=t)%Bv~>QpVw" E:3{S,HK1&ƚbIٖl}71'OI%{WPRwJG̀~ޣ̽s_ |@,L$N1l 2%PN©|^H}C XʯkۺurU׷;ub i1{9c^݇:{΁||](ON!{;N%]@HJh$:P&P B8)nfڠ"D7Ƥ](u?>; 8p8leƂ3y˳T@UBsAI̽$&⋛m2 ;ܵ 1 W_zkD1_D纨R?q⑺J܂sADDzԸ2&g"g:?{PH08qmk^äLjtBq_@<|;Yl2$suȿ-J8KԕLPx'ht ˗L7HqUOn&uR! ¥5׹<޼/"71x |ki#(QmNۚ9;3ܐS֥ 6\juJV^ZRt%IcF%i8oV_!TIn5Y8FM0J"s*n by{"H9ln>cX+**"ڀ4~X+cHS&Qu%aʕ!mKa;֊5.NqFRcx-asNV ԄHmt ]4мTb[&xԃ-O( Q7D7@}eYR+"PL $bwSm!coP)Ӵ3' K:ǭZdO`~7f^t鵖 5"K7VC{_g:[57vDS^F1Guݘbb Ȯ'H|^Hb$c>35:}(ry`#GqS9 ѷϏ?zg3BxL;w? 2x9?%^o _|U, ?|؋ӥsI7aA~U{〵a )>lXM}Ci >\RG@IDATl@R!sƫJ޽4#p: {{ι!ZvH$k''bT{FFKDxf[y?kYc9 Cpz'a\)2~~AXXU%t!QfHʼ Kn>)6jʐL V^6Lk$ J2;uv 2)b,"Q0Wd…7oRnGLl iƌ)ފj ~fMe%"> mH.S ½fly\KJ*pz O-Pj4^81 3)u ,J>Dc-zMk8Z`59Y DZ[CH* d`)ntja)MUdaݏ@]8XJ5#8QK?,@=aȓT<86S @<ֿsA8钋.#Z"[W`xx͚*F"yxq+&coK וwVr¹^ KL$eOz,XOX8Q;>wQR5[67w.*dGzy "gdd}}CUu w}4JecO92_0!c{na0`2I#H7|ww~2o%%x= ƚ5sVpc Y:d&Nɍ0rr >K4׭[EWש[0r"ӻ} <Z@g\V_ٺ` o}uE=v&͖[nIbYlπ[#k8Pqh@5)6{!v؃ڰ9 ؼ"mQW}qq=;lA|F<'?+.Es\ħ#Dh#)Oosܒ"'eeh6-Yga>LS cDө; ɇm(a-X QY]{[b&Gާ9ruSuޣ7~<_^^=!&`,>澎ۧ_1A->t=?[o%M;¿ݷoOerPj I_)5lT]Y3gȿ?#آMcsڇ첐&4?C W1{S{-[Ee:<#^|QD4^/\uh}vyGۦ>Gff1XsVj׵kǢ9Yۅ%3`vh]d)=kSmdX֬RCzK-EMߡ QYzu20m~v\k|䀘 )R$pUW0U_.`ܰyeD1}!g!#͙? bH_^d҅.lBU%([$Z׏?bmcgX|u-\9 0{O ^h9iҤ(&+ a$Jt[0ap)wc C.zb}kl}30s~G=8Bzg$Rt l&\yI~pBR)Ÿ f5#dhks_z7=RM0LW݀g% Q~D}~zVYiklXߧhlY`ԔG ѽgtLʦ#&1( ~V۵"z]DF@(AC&VI*jt[l*+r(|y=<Wƨe<:Oj~vE2-!f}Ժeݟƍ bK+C`4AB'q&([qM1<MΟs]q5up+lRFw^z V,?!ڷK0%l0Zd3RkH\waHstW^8ݡZ׸ܮQ"!J "DX=WZ(^Y8D"ǹ@Zםy p5aS;c@ @c75SㄮAW1`3n% H&xC9zV' kA\dsB!S"=gOY\'[ i&8B)F}=|C=@Y9IDS\\ ?sGx7UsvkٱƄ`' Z={`Nk-$Mxs) vLʼn5VFH+mF99zJȇ>-Z$ /Zv%E,Du*|Wu1~ty?R۷'4;"SOE 6]FW'|o1D`gU ܭ%Vk2d(v>oGlbyyyG|9EX4 F&Z~YfqM}Ն Fu2^=z(eAy%XM$eP=P7H,2"kx ê˺ v1fKMzB%H؞۸p#.]?85Ӑ2g1PfQQo2?Ç< D2AL~4d?5byٽ{\N=`Gxa\}T<5|뭐T SOqwr|Md [!"}':OճQ_q[Kd7^_LMyU:,ޠȲÆ5jEs@~pxĘA q URYz")@{Ϯ0@ `o|Lx饗E)1M@$5Fuܢ~s$kcK{?"p]OmAAz"(l/(Y㨉vGKoS`Z|LǵgJ R<74Д.̞kL"ʠ3ڶO.H_qI:cށQ MlߤS2$rbYMAYH1Xx7vpH6qo Թ#ك#RA&.p[|umcg5+/3ńt->=0M9 ARkQ)Xdу FxL6ή>"!`:{Ω [x6؟<ظlS ^boڼ5;!)^J.++K=X  u) Ñ09];X-v%ݔux |hϖk#PWՏQD3>E֧ ˯YgHmou ]BUZ3s(zZ|'ȉݧdݾ< xLA0\>tpԡ]ڄUVV $N!gAa3ѷ[٣\A5al̞ݟZBV}̂T(7m4tsF ga>R`T{ _Gµ_Hַ"bm&TY,!j2UHV2+b9V0#e-dX=$s;7C#ZyYEĬ@Cmn/^r4xīsņܢmԳAĜb\Ϟ=qԟ FrCD0,WJac-&dk0%1(Uꙹ{޾/DG)X VT-Yr`\xZM#m EUjݦUrbHzOn!(YӘ'@f`={5 _E~ )_b>yyEpm7"C jw)ӘWtG6ͨhIs?5bZǟJHghǙ5kԖznR;ŗ^LqƯ 'MJ{7Xw&]Dڏc-iƬ̏JVHXY6w-gHbi(24݋jĩ_]|ڵQh+wh ThҐOk%0 ;UZ3Dtc yHvE؜Pr*uEUTbVD4B(V۠+*"O->L d 4#s,> ~6-\hb^7D%OU>avR1T7#0[ڼ,5'uywKox]{I`lreRp{cn~L,<%וEynĻmBjG^4p0q+\b;W^{%|V|%?W% tm1gIi6LJuW`9; O麫"|&J*w8u@1U.]сbޠAԩ}]^Hjwlu( d ? RE!iD$64i+7\ΣSċ|"|&xλ~pJgJxnd ΧGn_piGis=HD.I}. #7tH-Hu92pBp$M\(#`ID46cOz9b Bi +m uJNc4֕矙ذMt](m!@ j 8kcb}_C?>py僛ԵHoĦ 0Rc"):dž-) 'Fydqcq3>Ev2+ywܷqc/cc% … +JiJ ^xP,*!QaACӖ̈OIC.Bm%LG4lfdQ=4ΗAYaWm Eɫ>skt=cDŽGk.20,,e*il#֝k5ߨ7|D*W_yXp)hWS5}Tޭ,]}U顇zq#ɓl|Ґ406k5uBt6<7V3 =] L Nb5+ΫT"F"vMށ|l"gp<&7ŨxT_/ uIEl Z VBkLa\I4]b_-QtdTVNƛo@QI`sU9Dl%sg#Zӵs29\~97n7⦮jg=J4Q#AtM YXQ`{sƬ4ooJJoP5p/Hu%ό>~o&añtlFB7]2k5\:T})`o2D=>*>BUo%~~Wwt-7OßX.q+41xX*//E'$Q D ysM ݵ~mZgiė㽰_~{o_o]K,[?_WJn.J U؏^Wl$pJ`z&YSGk>e?JS^[-a.MyÇ*=C 6fc{N4nG$X\g2N(6 oæM!-v58.С-B `uޟC+RE b6Qn3QtWG^j5O=$]ow3fL'%wrLoj O?xVĉ#k_D`FL![M~D֓?̳(ӪU[6/(F$0;׌KE,./h-yлaLw@P>z!X̾Uơ)kaĎ"J$ERϮy::@by 1e( !z7̬+F@Чr!mWݡMNuhBaKERE y(R^Աƥ19ZNgq W'cI_br&W @AjP(B+a굆|Z*fɒ%iⷩ7vHZiiWׁ9R4㋏GI˗W>>u؉u>D\4zX:t: ?J:i:/#GiZi2<;JL޽HdvV$\/'d$:`4o/9[Qh섢~BԶ}TVQqO{#+/vFa+b>LI^^s 7Lv3[4%}ʷQdnF8TUz([61*)[>;w zP O߾7##gdJ"a{p30g6>= ` %Gդƺ쐢3_?uKMavHs{EJᭆb2J)&2Fx!",QQ5`x H.BnB^!)^qH뾥~|)tM(a۶d!^K ʱ a~"jn%p.͟cܥ11L /:z~W{xgޚ Ќ=jt&쏴eW𣘭εl>wUX|b/NN]3 $ҭ"Gr5Suڵ"ݵqFl +m1<'KVi9})qQ/2JZaKCB9DK=_!?!;pIX/xv oD jv) `C%̇ "ތqt 4 e'?I%Wv}uwo1݆ar$NZ5/|#U,IwO7GWC}95<[׫VVg 7S^/D}#:y5nBDr(X,T/^,n@M7 6+ K"B:F⋛܎A l㜇F{yM7\k]VB[S:(!h1asfL-2y2QPtkE45"˩ViW9cec"bHq|;Q8 I{ SM: v! v 9=Vj+]w= ϾI]BBbm= ]|4nj !r~t:lPֶiڅ'K}LmM6jҞ_ERFr M7z]򋉛Xm dMOmJUT3*b Ԙe*r=wNi{YYem={CO>5tw]CdW!ǴBuR+l̘6'JG᏿O@R k6R߯_$ԅo|3])1~__wF Tyr:|NeD&OFHm)׺5+p̐X ʠA|=8U1{h:ѹDj}R%t*͚PNJ[ZtyѰc2NPuU5Qf7‚"'bP%s,ԃX8&0Z9s|9czE?u=}E@5X͚n A@d4XXA\Y^$e|*oK#^XK<`+V(q /jl߾ Pȸj7h-[hF_@"wKb(r @&p'"4:cuܟ"7"ӀR's'F|4'y-7o0i' u] 4 $Ph'z 0CpIK,w=~mk}S.f^T|oUŗ' t9gkT2$V( Fuuuk [Y+-;q4f/[79FO+`Nr /M2ee1D49g /lr`PM 0[1uO?/l d~s~a顇]w,x 7\Rgp朕LrR 8\#ri{ìga)zkɕ, fe+u qB5 mOqzm u]L$2 a5uwRaQ Hf8Z *+/*Iײ2}I*(;6trj}x>ltM_N!_f?ܸa{5|\9 IA@_>pom7T܂x{yoSF B*08gtt~J)E6PFM܏|ɊvmrӢ+api]#"fF֭@XA LJ" .0 \+{&%lR8qo#%"i1lw-[jl*לҴ9O`4nܖʾ:<L+@˸q;8_z=slQG֜TG<=AԿje˃Cۈ\W+d$jB a@4x4TQiV\:o %I 3grN$N1L}sp_@U{6E<? zG̵,>oVikzէOoo/TRތxbv@ awLW18) Σ !M^kGg2u׹n&XJr 70>DQ/1ȭ z{$v^j0NDznMRe%m.-yoq8NԷ|wF8%BqF;e# Jr|ŋл?[PŘjo&8WI/]Qy**\(->:rb"} ̀]wFʟb )excWŽjO{>ZQ>E#=Uh%Q4hpz90UKXΝcmC,yT^nd=On"Aipd0/5Y}e\Ϊng'6l(!A3*Urja k?I{~ T-E4i^n 'M(D۽9,r+?(;x+/@@ ZS_w aB@H_·PzoLz.̥n). ttֹ^vH[c eJ=⊶ /5LyKpAZCtM̀)5pZ˪5 ST VK̦ty-d~] ㏗,^CD+ۓp'rZnjqZ޻tmxv=ԗ@\2X^XףLwRL|߇Uo)t'JA5í],&Q3j5G= E1Hk:nJOXcMmG-P 2gyYqkWpqoj׎fiͲRS 8gB5FB};̥>L{8@LJ|lڠqhTSo]Wt͛k@Eq߶Z(pU| s0= _vii߾#3ipV0+!(1gWyY^Rфiz*R]f?\W]uA1M_NӺԾKԧW_ps.Ybm¯n2FBZ61dt%:BLU!eJD2X Q@2Lx!;tD6X}\0r5a<o(aes;$Ru6D EG1 Blj:s)| G~|o5e!v㾸/ cc,;lj6C*¸;'~<]fӔ.// ׀PΩM14^Q%"Ts`/|}ےR{3xNc~r}xG-[yhǭUmLi;w!Nnh %OIm]u.%g5s Wi.#uӹ4$EՒKJN'gV-* AZ]fd0،B&rfni&BG7WH81%؏ŋDɓGJ+c%J/T8WT gNnw#ܵN۲us0c2_d-^kp@LI`[CkFK:ۋUUrjḭo'F"Uj10Xӎ8-/aJ=K3Ϧ'})mٱ1}fɯE=Ec?YLXJ6nC*TY] zA bj~ HT_g.ks2<[t~ܨ> ibT ô Ζ6^c.E&ջђdҮEQ-5BTl3gr kQE C&$HZUzgXw%%!SB`X57w"ڎ \~*c=^E-u؄DJ@( Bo[zY16:ctO$~(|ysi[euhH*g5'D%6Lx&56jAw[\HAJkWA -åTꆾ84-No* 00ӿs{vԲUҌbS4PB벟D-[j`ht5o|bKi'1|& CX:,\z}kVNz)L ~yH(/،znIHjWoXJT٧2/#j[0+a~bȝujߩ=I2PCXڴmiee!qi4ZW[UN"|CwC^cFnJ6A fY׮,V?Ōpi 0v/uYE@hCmWuc4nCD7TNB"Tq}fV/(@A5Z|)!T ;=pF"J:!HْYB&5n)1( <ܳB1dWUРji41xϙ㋯TŒT%V8xpwPNk_K7p^$"bs B{" K5"&^ 5MWz@o?E8ֆܨ7G-|zbD}z){1kJYȉϋ/Fv(m pk !jv?B _[H7Oel%V(ev"oJSgNo/]Ir8M7maßx0L\}WiAq,JD´5llٶiY0(mQ"aWZOnUv RWo@ 5toY_W'%u *kl!^ؽo]33|'r6 I$wD `eZza]xiηWZpW(u524ɡ%W_~cv]4:o5]uH rS$ݪÇ Bo?1f pBpخX%T 4 Fѭ"q,,89{`X[Ϟ1t LkWځ= aG#]=< 7Ru HI]$JnJ=Ѓ?{0MV_FqP<޽{g{.Y]颋&H:mqMwOPopG*C? PDtCc<(lMHa 9`g%RP] \o Dc؜VT쵘^_Zof$% :0 ڲF*Ȫy¥ƓtXvHc!"xo+V9Td73 Ʉ;.7R[\^:T @}=4d3jD}AqӊǸWތ8vh5 pX;s*ܣjp"AJ;wW=ztZv1DZ5vHzSjßV7n3ڢ^xaяVwյ)MfAg'"\D~8iӦ~tѥ!|Z^H&ȷ)_Pt5&֮ q(A Y3\d)Yr@ |.{ӌ1 vPzp=H-1zweHVѬY@mmOzDǟraV5U[[1o.^2m2. |x֡CԋX1rx? drQE I[e=]CG?>Et??24I ͈g`!Ke<8p@TirdLtv5rIÏ= qhb"H+ͅ:/DEonŴ_xQS8@66j\A_QSXWA ebEN`G 1޻zr>K&:$`M2<>?\K!T7C-fWb T;"$!%\PUl5qRΫv|j]rK ohy:,MvT_妴j4"8Xwl"{\~\طQrO oTaMFb#GQ~],..ic#X+љ~}mC [Fd )ȥ-4`CHqӧ ]u n>L]xFR[U'HAU?udwU5OD;u_BEN=tX, AڍV[sыMoL;VY\ j6mc\R6hv̵x!WTtzO4ޅ8a/c~*53pک%EURx(1ꄆ'^zr SFxy1"z9gkI="4TUf|`Y=TӤr ܑZ9}by۶H  Gi/*CYEtQse {5a ͩ/~3r)#< *Ө#}MoNy=3r!B/\^p~\˩v>D'73wvij,ӄqނpM 67T aeRHz $:5{] G$ - vGN5fȾk^.ϫc>3aY0q1WM݉g6&]WJZݛz#బ{a]dTk*iRAњ23ƍDez+3g IǰG{<5@varND[Y3ڸ8ȅx?;fPoyg/UACk;ZC4A?;v4&3c"IXvog`q}ևTXr̛7/t{%_~4p{Ϟ3Dc**+Эv ؋F}A|eDp^(-!/.~\ k+Fv =Ĩ[2*ߧ%˖BQJ Vϫx%X6!&Ge5寏k?{Mwz4Cbe߱ ّ>dSON 夊|&DL{4jҤ1!66%v2>&Gu ƵݑmAUUT˯wv}Hy Me崦Z[o dfumݲ"ZzKڸn-! E=oނwSB|;|hSm⮈;g ci2hg1ݘ^U2ΦC;~7fmڴcK/<nWCNkjjb&o@||:W5QQ^羇wg ڧH{L6-9[y!w_3&B( k> hD],V zݥA hSG|AM0I綤7^#!A7Fץ嘬kUU>ar 9 -|R+IabXj5bTVVL2UCsjd-8>ԉpO^EGz''-aysڰaHyT,s"`Bph%7 Mp<3}p4iUbRNݽsOTV5cN7~jMW^'W X†J>u~b]N87[bVX1'wӞ`aW6C0ڒhΩH*\u&rJ ٺER[c2[]G'2\9\'˭/'$m[$$|%K,M:5}$ƌqЫ0CTo 3/]ƌdusaQA4Hl$4zh6n܀oyA6uz>mf1VWWXS._O!h=:&8pb~(lIX,<B=ZynHqU n b#ύQ1UG8%FP6[1{DQ]ӥ ٘L'丌5(DX ]g{#-EwCˋ鷿>ԫe騍CU0eWgO6Ʋ^O}w k,x"]rki ޸wÕ8N=Cẋ!{ rJ%_"|LWYUMXf``+p +ʽ9Ɨ_و!+7eyZ7] 59@#p/"m]'c)\WD{1 qʐPYIGQ]i'j&޿/@S}x m7>2{FX83 =kxڐ!Cy=Doŗ\qGy D؋h8׬Yh- };waH]s~`Q\Q;}{W7q[7AFs"<0ؘGѭ[Y0~@cސpܿ Rw$ZHBf| C7LnծtŗQaXKe 63z4RN d1҇PkCQtWy6V fUf`ɚ fa #j|wDVR%u ;c~soһ/SOɧe,IK;꼑;9\ JT֭Kv\}Uڞcylp,ۃɵ."^Ł\guwIB YEp f+JUDV0q#*Ȋ+Bةƥa5jd"ZV݆KTZ5![5bo/D?{(H^D@B|  Jy.rkkpxr! eҫw/\K>SfjU5֯JGNsْXvZOfcEhbB,oVUU{x8{2u3 X$@Z.a7fc :LD4I*3.H"}3Gv"|W *pĚc{)+hL `"DWq{$YֽNGo ƍp4g<9xQ(\XWvm8N%<(XOp0b|$4񮗮6nN"*W #֮[`xV2F<<H.{ =8Fv5$T}S uv2cNK@,>QpFA=PO"p*p8Mb-[(EbU#5˚2Ϗ<3钋/OFEp-55is'TShgfNy X)Ӫ  $\ui̙0h^蟞 Wg6+)SAn^韠EC6t4TK4͠ŋtjp d={B.EX:R]A"N>)xn0@@#Tj6]ؽh08gP{5#D뽈wq$f͓PdD~s>C *, J: @7uzUGƼ]˗2* >!\Tp${j"RG`#f^ >Yɹ˜ZNp1Vi!x^~cr(ܬ_q"O8•T,NQ]WD8{s͖H:QrDI<QpU;Di:('5Ӡ,=5Mon4՗. n:w<ߖ=!`guc?SJPWzk\?e븾x)wыzuPhV_5k4`Agfn`uk6ׄK}RQ{ކ?}ʔ)P4bKlڐ֯Z]o%bX9y&˿x 6}y0ertoh(ڽg5AⰚJp؏B @@G6#]"|-#p:DY{Ϝ9́{QD}JK&(FILTґpێ9-}AT1c?'UмgplNӧΦ:xH/gCh\xV֕7FAףYWR.<&z-ƌ5z[$.@q-@BAP'XR/x yI]+)g1yNk{2hMя(QFNj#U 3m'Gu8c=8k7igF6,V,_ʺtOO#dCG%ɦUH3e\^|2ߊ1Ÿڸѩi/đ~}Ct?K4-]J":u`!yd{v-"3 ۷G$ L?19Dd/(ʁO"ܱKnE)g&mY L92/_J9vDYx+q+z)2bWA?\ !Et:$`UkĘ'Muf"_e;7|?{d%!B㛝mVt ]kX ➋C ,QQlRVyk.sHt?~gh|EzSeyYT H9̳=~#PWKH0#16)b7Ӱ"x3OG> R{f;L:GO?ĺ,9bXH^fF-QUE*0X*mEҒZn8v 8[P,kz"C\?a[*5t3iᄎ!U_5%Ê|bD .K7rb~H?)8ͷ1.SŠlTreA%j2s̩B @…Ʋ<P#$ "; 4K,1{ϞHmMcr}{ ^S~kBVfdƊti\{0rX]W}$v͏`Etצ9ʏ>)H ED6]^睃ʵ¯ZRLYc\qL4rԈp)>*Ei}^tY󮻊%6 ԯ5bj0J}<ʂHOW 6hLQ˩\oĝ1W7M$zN7;/TeP`7=' -mng[nNGKpNMר{eŠ+Yg"#{Ϸ>ܕOtt^L0h5ڼGz wq E)Xo12./b,%OHY*";G|q䴆ZkZ "aQύ?+6ߔ{3lϞȪ]p`ǥOwo~_+vlg'ƪ^Kd!͛;|,ˮMy-,cӈŋC(b TPG?. ;P)㧴luc{۴k5I^( Sd4cg1f,5T$)"m"-B?o0NysoE`LETG`aeq}F舰W$6 zmfYgc6ά.ZˡpӍ7[٤'¶^ii5d&<{V,oZ´wfQ7|۱ ; (WP3YvC>e0~/Sw3Z9f]W¸XJnǻIO%zgi,nǺM5‚P|%WMK,1frΝ@f[PL$ςEe ' ,YginnF,Qͷ(LYK+H+?_;ⷷYwGZ)YW-SPFfSů"i7Hv0ĉd0a| sΞ'dG`SX$ZkCLAe|aٔ@fwWN/&_?Oɡ@t9kV!n{f@A}V( 0_ ރsG !v_S Ȅ+@'Jd%`ku05L&d2 AB!`EAb^ԯ_nj}>\ˌ%|葡+~Emԭ[W}@.]$zKX̛mZ6a>/jvvک|tذpDaQ2sԩmcVIF$* G480;+bL/]&^qf^20XJMXU .^WJA~NAHѿVa #-'w6ajYթZuqky?nB8>KD1}'|2t5=e:&\yzX+GzЂYhB2]W\em^ ?ݪ:6ODjը8DFECn0}tjVEJ<abHf0 P=X.+:&|4UayEsB} ⮶ד\]pXR7!w3԰acHJW=D "mn%j &W; !9!cN,MbДp.Q Bz+ 'Rc D۾}0Ǎݥh";S=K~+۰⍩=QsL ( +fw;MxڪlY}mѬSw͍zTQ{TR2~mrԙ \t (~+ӞؑUDg65D@o*C]0zuZ$Yy/ OWZTRAq&o* $, x'X?951#姟[ &f)Ly`Z y$Im(+aS_(=aD#2Kpjb̃1VηM]C|0=wci=;*}NώFy>f2BUݥaoT5RsqЩn"Wܷ]/m5@Œ]PE 6A']e!S~R=^hs &%Y\#H^]œ8h_+8 B- _ϘQ X'gFWnn}Q3ݻbɥU;@8~{dc auaS_W[0p_?ip9t[C׮݌"xt#a/.Y=x Z]B7vwkTaL1@ v+ kwո:R,F&^25OV+DlvG81$| l#Xx$5~|+!N B֍ 8 ģ}@&B "}Age"ӳ gg!l}CQ#N4%bCzxb3`ƌwD+Lz[p(fΚ\B$8[jbLv B6I iɟB&l=nKf9Ɍ^bD=8f+qNN^,u^N G=<$XAIg9]oĖQb9Ubi,׵ IF;|r-E^\W,m۶q-7aKv~6 /F>$rMȱ⥟Tpe1C)ky˳"UU_`6s +Eҳe3r~"6*Ra7߼E30F+AO5 >hIvu@YL0!9vE;i.IFW, Z<ޟC| 93A,:"B8H=OZp:۲d23Yf!έS۾-lv֙go/F^d"*")^cp"hkݫ& uf$dTgGMh'I}N2"Sdvo=xҎxroA&J5Kk!u^bq`V),Ot9rUtȐ!J}N>I҄AM|bdxq NіJJv_YwޖBqqqlTXL/r- v޸T/lЭ[ER}?<+/& vL)S&IUNț jKP /M\Pb?+}TTr4@ ŀ=|"Emۮ0=S|!K-gBmIڇy00E=DG~eQ"~{gxhfݴi9Uf͚nvL aHjqq˓@!!טARSn U\w\ pq=%ΓBvZm{8zmV|*W[h3"ѷMKfw|ۖ~Ԑ ex+iޑddʜu63]5-%,~2uԤ"IQnumL3X>ZS]"0pxux7ܬaݏ!7볤 l,wSO3]u^k :ud+b7O2Ę KD+=!śV ş ك죏bdo'MVH53c5ɍ?7~ؾs1=jaeRTYP#`Vم)`"EB; ( vb,Dz=]I޽BܺC/]{<=9g#k>{2%y&#X^.ca3 յȰK鮿P.%1(c&(:IV0:c8FjM̪cC72]|DjNOuNR=`0=k&IGVW$ҫ̳r!<kR+~x{~/Ed!閛o~Y0YhM8QW^}^Hؿ Н?pu7doRTA3g`w$}X1F6~|&{-q! 2 7O8`4^ d<*믵8B1kE C@~$\!MP/Ir-bfpb'~?T:gi: Lz&@EF g ?6SJco==ZFNa%86d8C 0wӵvr#i-c-6]° C7k$z}e^|쬸{lșiqE罒.: d*H_lP3TMޙ5,[n6O6%ժHM<쇮/s|p\g2/RP H'v|9MN moZcM@_?-uIt@ͿF=y` ɐ_/z1B=|Ja xoEw!իWpYa$І跑|SbĹTfqo22AT~ ԕ( !€%T,X-ZC$l]W]Hh6)Cڏ =oɚ3l}U"7U缢XJ`p;c >yVKЧCC:Ї?\(-;߬=w"L}COd*~M@Gak֢Q 5b@d_6`) qc.|#ЏKGƂ8rhV)s Yu$[~9=ڍ. e\>>|x%&e" *lkK\"-,^HS*޽Wå]?M|rHm uHJԩ"|&7;o;|P[xl~??Ⅳ^J@kآ  ~"W5c_0gVK”bώ#fXi :^$1pΐA d[#_ﱠCFI_]1A4^9 멋 2?$m۵3Y #yh- }'a ?Ԃ9GWI׫˅vNu2W%Q3,a#[r?(,GHeDzLVXsA dw& rj$@&Λ/'c,<J|73@< ]ffYhqAx  5ILtƼ&/d0[m xfc* >1Xh=L=i >'d:3r52?Ɔ{ ߻imc!ҙ.CôBh,jDw͑ړv`dWT}%8z7sEC+k> Jui[V`$FF32[HPrq&N|M"{ۿ/Y=^`!LݲWnڴy}` np"ȴR$&,fKgi*l"-H|f: m~q8‡ה 4Fb8RW<^ ~,`#6iv&m-^#UK :jm1f ;߾)~m3~x" ? ied1|1Q~xhT`5g7r"|ѽ\Yg* Pm! _ H}plCOc,s @ AI*!+H֒`TtU f5poǦ(1o5tOHTǎԪڞ9] d ؄dՕzK?ųZň`! c8A+ş&SC ęPmD O?/ږO91.&(TS~'KI׬Ye` Sb,: CΞ=GlHcBWE2\z!ka /TB?f8afqŕWX$/ΧKv0X 2vX {0 70!d[F!b, xk%:#[/AZ*/,ᚈ|/zs7K3Q#Uφ SI=?kakad' UqXTgBw͓/ =OO67*St1rSg8xKE\ˁs(d!;v4}4_^HV[^kdZfǰ0-sYX*R1d>Ӹ\1^LHfypB[3SL1?lt>⳴f` a)` {B, $59oo`L ;i׆ǟxRgKf2^xxҪ Pq]ګW3gV84a;@ve5EUV>>r#* \X4Y! -W,=u؃wM]j K/6 zm4NX-uQ] EuTP=a/nF3DEbIoXz w9*9g>vB(!u~g_xQufj:`bؚLp_ n/#oFT hx`7KtA )oF2D^( a]V`Av]hQU_VR@wX,bn>f! ٪Odky; S]֋ ҘoNKUŜǙ>"dQA2/G3E \'$ۗwyŸ)s9?c.z-SH $C4}TcI?,]wUu׎"QTQ^oS=WfUøwзQآM`oΕy=X$k E1EMxVA(0K{9ĭcW>BX|83wa1Ŭ]' >,th1BSGaT~~&sУ'M"jTHE"xBw"ajsF:iq<=)SK盌p5WNW }dhAKmxޮ"1;l_~>}d56[%_d?sy7UMDAQ|UX9SLW{)["/{=#R5 \βz*P}_hC@Gg5$g y*{|fwe41W\/m|Wt 9KghO죩wcԈgYSQ9۷;HWg:d<$C%JV&oֱ:JF?Z)L4Cy$Djf۷o+[@De;^$̐ ,0o*&48ș:uI/9[xrU C/}$ǫ´oowpxmUX]FתUlr"TYr۹RU3$+09dkjJιMb-fY_,aRjf k2yĞKÃh,Ss0% 7ό0JI?{WfQR +gChG֛9@FMAHDb3V蟟=J >ll*E'O 'rhU*W\[d=з"Z u TdL*]x>REkwbEжXB%kXFyC%Hlּ' ~W1im 7 CTWx 3 @8-o*;L6RV@Q ,XScUV3GW_`vb3/;, 닥3˸W&XHԮ][=~&NO<5sQ]VISh@\ZI <NL6tW޲kdφ`ۢ]EѠ/$v E{ńo||KPhxg_*Gg|-)~$o{J%K8輪`A^zASR ԇN<`*8-n>B{eVD3lccfͥ {INNC8u+=;X}Ĉfw=rDS"@j:*/ ]Uk% Tq;WZ! 'ÛO].Q-)-ys 0BN!͚IG/ :̯Іpà`#ы/)g'p0wͰe),bX Lt,VAquQpYڱoy -6uE}f;+m%?BMXcm(RfNy\}NReQn{Ji=m Yjc{|Q^}ks_y81-={۞KX3VzZda]tt /0gLZ o{;q0f #7P{J"%b m%!&xl A,@1|L {Igayn /Vä,s3[pZH^P!߹S|ƖԆ{(b -ޭk%lzNۋr@VPT"ӱN<'GLyM LT͚f,nvBY"tEQAbuX2PSeKV7D'*oZwJ%? }#,JE^^wwpcY}}@y*%˧DA@Wکv(8ndvbc [E{E=R:0Pd,$l ^|%, bРaaU@V:ትgkoϩLm C_j7$,#VMYQwa3NRKA"VmaDM6@>B*,t\ܿm l{y v "J.@⃏;$. yTq 3=L?oCv,6bƽ~L/Gv꯾:Yj/MȢұ4;/cOKd# 4z-&Ul1R*LEmWN'sڴ-q; % f"Fw@lCc0Gt4nb`m`;uEbZ%v$܏<:k I,hrxZ-1X:_ISƀ86@!^P/Hhۤt2X1sqDy#¦)3Ť[TL+L̐cv P*#( >A4A/LP@"@%BORhC%(zc%hc[{]'_Cwb ]ϣT εy>eN~i|<_й?ǀ5Fvoyئ)9\?1OS-;1ӭtX62}R{JcB\p~݌J/-T'lHC %k'[*+N+l2mB'P:ci|?"0 ҚVnX>הٺ0! `GQ)HlIcv|Z\'5g7{9[2yْeJoeÿS)o >%~^I) [.,`@}P(kzXZKWRZy[; 4=htؽ>dG-)yY5*+1/ {Gpǣ?'َ};&-ܖVgG^›E,bZ_k)yYO*Sd(~pρ{cYݑÍ¿$%@$tƎLϞ(;fď \Г7X3baN.yG1Y6K+[Zoˏ2e=u5娛WRIң"$ooL~Z,I^,{שlZHc,4(3ty*+%s,ze#fɾX{[ɣ!Q2~MLY^<K^ߏdfIWLp#=c*KonY ݏexD~)㩌qNщ:FRR1s.anw^9z=#`.0Q$Ә?eMe\b¾dꋷy2X+%Kgdhk:a;}P;UϮucvꦍ7dEc0ԧ3:uN5'q]Z)n^\4_*u;&/RmdՍ^GwJ9FD|TU;dZz}Ee_Ƕ}g1V!vƏov}{%77jI5d8Q"=V,O-U}(dxŶu*؁V%oòCo6糥Mr|U1%mym556Dz^ZKo'mez'y6w9f}:qr\ dict[MyProjectPage]: "List of pages" pages = super().pages return {key: MyProject(value) for key, value in pages.items()} ... ``` !!! Important If you subclass `MkDocsPage`, you **must** re-implement the `pages` property, so that the `get_page()` method returns a page object of your own class. If each of your projects, you can then call: ```python from test.fixture import MyDocProject, MyProjectPage ```fralau-mkdocs-test-6f20fe8/webdoc/extra_requirements.txt000066400000000000000000000000461504167644700236110ustar00rootroot00000000000000mkdocs-alabaster mkdocstrings[python] fralau-mkdocs-test-6f20fe8/webdoc/mkdocs.yml000066400000000000000000000016341504167644700211310ustar00rootroot00000000000000site_name: Mkdocs-Test site_description: A framework for testing MkDocs Projects and Plugins docs_dir: docs # indispensable or readthedocs will fail theme: # name: readthedocs name: alabaster repo_url: https://github.com/fralau/mkdocs-test edit_uri: edit/master/webdoc/docs/ copyright: Laurent Franceschetti, 2025. This work is licensed under CC BY-SA 4.0. nav: - Home: index.md - How to Install: install.md - Getting Started: how_to.md - Advanced Usage: advanced.md - Testing for Developers: developers.md - Large Projects: projects.md - API: api.md markdown_extensions: - admonition - footnotes - pymdownx.superfences - toc: permalink: "¶" extra: logo: logo.png include_toc: yes extra_nav_links: GitHub: https://github.com/notpushkin/mkdocs-alabaster plugins: - search - mkdocstrings: handlers: python: options: show_source: false