forked from github/pelican
feat: this is going live now
This commit is contained in:
parent
eeaffe79e1
commit
06cac4589c
410 changed files with 4684 additions and 44715 deletions
|
|
@ -3,7 +3,7 @@ root = true
|
|||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
|
|
|||
30
.gitignore
vendored
30
.gitignore
vendored
|
|
@ -1,29 +1 @@
|
|||
*.egg-info
|
||||
.*.swp
|
||||
.*.swo
|
||||
*.pyc
|
||||
.DS_Store
|
||||
docs/_build
|
||||
docs/*/_build
|
||||
build
|
||||
dist
|
||||
tags
|
||||
.tox
|
||||
.coverage
|
||||
htmlcov
|
||||
*.orig
|
||||
venv
|
||||
samples/output
|
||||
*.pem
|
||||
*.lock
|
||||
.pdm-python
|
||||
.vale
|
||||
.venv
|
||||
**/LC_MESSAGES/*.mo
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
|
||||
# IDE cruft
|
||||
.idea
|
||||
.vscode
|
||||
node_modules/
|
||||
|
|
|
|||
|
|
@ -1,687 +0,0 @@
|
|||
import argparse
|
||||
import importlib.metadata
|
||||
import json
|
||||
import logging
|
||||
import multiprocessing
|
||||
import os
|
||||
import pprint
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Iterable
|
||||
|
||||
# Combines all paths to `pelican` package accessible from `sys.path`
|
||||
# Makes it possible to install `pelican` and namespace plugins into different
|
||||
# locations in the file system (e.g. pip with `-e` or `--user`)
|
||||
from pkgutil import extend_path
|
||||
|
||||
__path__ = extend_path(__path__, __name__)
|
||||
|
||||
# pelican.log has to be the first pelican module to be loaded
|
||||
# because logging.setLoggerClass has to be called before logging.getLogger
|
||||
from pelican.log import console, DEFAULT_LOG_HANDLER # noqa: I001
|
||||
from pelican.log import init as init_logging
|
||||
from pelican.generators import (
|
||||
ArticlesGenerator,
|
||||
PagesGenerator,
|
||||
SourceFileGenerator,
|
||||
StaticGenerator,
|
||||
TemplatePagesGenerator,
|
||||
)
|
||||
from pelican.plugins import signals
|
||||
from pelican.plugins._utils import get_plugin_name, load_plugins
|
||||
from pelican.server import ComplexHTTPRequestHandler, RootedHTTPServer
|
||||
from pelican.settings import read_settings
|
||||
from pelican.utils import clean_output_dir, maybe_pluralize, wait_for_changes
|
||||
from pelican.writers import Writer
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version("pelican")
|
||||
except Exception:
|
||||
__version__ = "unknown"
|
||||
|
||||
DEFAULT_CONFIG_NAME = "pelicanconf.py"
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pelican:
|
||||
def __init__(self, settings):
|
||||
"""Pelican initialization
|
||||
|
||||
Performs some checks on the environment before doing anything else.
|
||||
"""
|
||||
|
||||
# define the default settings
|
||||
self.settings = settings
|
||||
|
||||
self.path = settings["PATH"]
|
||||
self.theme = settings["THEME"]
|
||||
self.output_path = settings["OUTPUT_PATH"]
|
||||
self.ignore_files = settings["IGNORE_FILES"]
|
||||
self.delete_outputdir = settings["DELETE_OUTPUT_DIRECTORY"]
|
||||
self.output_retention = settings["OUTPUT_RETENTION"]
|
||||
|
||||
self.init_path()
|
||||
self.init_plugins()
|
||||
signals.initialized.send(self)
|
||||
|
||||
def init_path(self):
|
||||
if not any(p in sys.path for p in ["", os.curdir]):
|
||||
logger.debug("Adding current directory to system path")
|
||||
sys.path.insert(0, "")
|
||||
|
||||
def init_plugins(self):
|
||||
self.plugins = []
|
||||
for plugin in load_plugins(self.settings):
|
||||
name = get_plugin_name(plugin)
|
||||
logger.debug("Registering plugin `%s`", name)
|
||||
try:
|
||||
plugin.register()
|
||||
self.plugins.append(plugin)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Cannot register plugin `%s`\n%s",
|
||||
name,
|
||||
e,
|
||||
stacklevel=2,
|
||||
)
|
||||
if self.settings.get("DEBUG", False):
|
||||
console.print_exception()
|
||||
|
||||
self.settings["PLUGINS"] = [get_plugin_name(p) for p in self.plugins]
|
||||
|
||||
def run(self):
|
||||
"""Run the generators and return"""
|
||||
start_time = time.time()
|
||||
|
||||
context = self.settings.copy()
|
||||
# Share these among all the generators and content objects
|
||||
# They map source paths to Content objects or None
|
||||
context["generated_content"] = {}
|
||||
context["static_links"] = set()
|
||||
context["static_content"] = {}
|
||||
context["localsiteurl"] = self.settings["SITEURL"]
|
||||
|
||||
generators = [
|
||||
cls(
|
||||
context=context,
|
||||
settings=self.settings,
|
||||
path=self.path,
|
||||
theme=self.theme,
|
||||
output_path=self.output_path,
|
||||
)
|
||||
for cls in self._get_generator_classes()
|
||||
]
|
||||
|
||||
# Delete the output directory if (1) the appropriate setting is True
|
||||
# and (2) that directory is not the parent of the source directory
|
||||
if self.delete_outputdir and os.path.commonpath(
|
||||
[os.path.realpath(self.output_path)]
|
||||
) != os.path.commonpath(
|
||||
[os.path.realpath(self.output_path), os.path.realpath(self.path)]
|
||||
):
|
||||
clean_output_dir(self.output_path, self.output_retention)
|
||||
|
||||
for p in generators:
|
||||
if hasattr(p, "generate_context"):
|
||||
p.generate_context()
|
||||
if hasattr(p, "check_disabled_readers"):
|
||||
p.check_disabled_readers()
|
||||
|
||||
# for plugins that create/edit the summary
|
||||
logger.debug("Signal all_generators_finalized.send(<generators>)")
|
||||
signals.all_generators_finalized.send(generators)
|
||||
|
||||
# update links in the summary, etc
|
||||
for p in generators:
|
||||
if hasattr(p, "refresh_metadata_intersite_links"):
|
||||
p.refresh_metadata_intersite_links()
|
||||
|
||||
writer = self._get_writer()
|
||||
|
||||
for p in generators:
|
||||
if hasattr(p, "generate_output"):
|
||||
p.generate_output(writer)
|
||||
|
||||
signals.finalized.send(self)
|
||||
|
||||
articles_generator = next(
|
||||
g for g in generators if isinstance(g, ArticlesGenerator)
|
||||
)
|
||||
pages_generator = next(g for g in generators if isinstance(g, PagesGenerator))
|
||||
|
||||
pluralized_articles = maybe_pluralize(
|
||||
(len(articles_generator.articles) + len(articles_generator.translations)),
|
||||
"article",
|
||||
"articles",
|
||||
)
|
||||
pluralized_drafts = maybe_pluralize(
|
||||
(
|
||||
len(articles_generator.drafts)
|
||||
+ len(articles_generator.drafts_translations)
|
||||
),
|
||||
"draft",
|
||||
"drafts",
|
||||
)
|
||||
pluralized_hidden_articles = maybe_pluralize(
|
||||
(
|
||||
len(articles_generator.hidden_articles)
|
||||
+ len(articles_generator.hidden_translations)
|
||||
),
|
||||
"hidden article",
|
||||
"hidden articles",
|
||||
)
|
||||
pluralized_pages = maybe_pluralize(
|
||||
(len(pages_generator.pages) + len(pages_generator.translations)),
|
||||
"page",
|
||||
"pages",
|
||||
)
|
||||
pluralized_hidden_pages = maybe_pluralize(
|
||||
(
|
||||
len(pages_generator.hidden_pages)
|
||||
+ len(pages_generator.hidden_translations)
|
||||
),
|
||||
"hidden page",
|
||||
"hidden pages",
|
||||
)
|
||||
pluralized_draft_pages = maybe_pluralize(
|
||||
(
|
||||
len(pages_generator.draft_pages)
|
||||
+ len(pages_generator.draft_translations)
|
||||
),
|
||||
"draft page",
|
||||
"draft pages",
|
||||
)
|
||||
|
||||
console.print(
|
||||
f"Done: Processed {pluralized_articles}, {pluralized_drafts}, {pluralized_hidden_articles}, {pluralized_pages}, {pluralized_hidden_pages} and {pluralized_draft_pages} in {time.time() - start_time:.2f} seconds."
|
||||
)
|
||||
|
||||
def _get_generator_classes(self):
|
||||
discovered_generators = [
|
||||
(ArticlesGenerator, "internal"),
|
||||
(PagesGenerator, "internal"),
|
||||
]
|
||||
|
||||
if self.settings["TEMPLATE_PAGES"]:
|
||||
discovered_generators.append((TemplatePagesGenerator, "internal"))
|
||||
|
||||
if self.settings["OUTPUT_SOURCES"]:
|
||||
discovered_generators.append((SourceFileGenerator, "internal"))
|
||||
|
||||
for receiver, values in signals.get_generators.send(self):
|
||||
if not isinstance(values, Iterable):
|
||||
values = (values,)
|
||||
for generator in values:
|
||||
if generator is None:
|
||||
continue # plugin did not return a generator
|
||||
discovered_generators.append((generator, receiver.__module__))
|
||||
|
||||
# StaticGenerator must run last, so it can identify files that
|
||||
# were skipped by the other generators, and so static files can
|
||||
# have their output paths overridden by the {attach} link syntax.
|
||||
discovered_generators.append((StaticGenerator, "internal"))
|
||||
|
||||
generators = []
|
||||
|
||||
for generator, origin in discovered_generators:
|
||||
if not isinstance(generator, type):
|
||||
logger.error("Generator %s (%s) cannot be loaded", generator, origin)
|
||||
continue
|
||||
|
||||
logger.debug("Found generator: %s (%s)", generator.__name__, origin)
|
||||
generators.append(generator)
|
||||
|
||||
return generators
|
||||
|
||||
def _get_writer(self):
|
||||
writers = [w for _, w in signals.get_writer.send(self) if isinstance(w, type)]
|
||||
num_writers = len(writers)
|
||||
|
||||
if num_writers == 0:
|
||||
return Writer(self.output_path, settings=self.settings)
|
||||
|
||||
if num_writers > 1:
|
||||
logger.warning("%s writers found, using only first one", num_writers)
|
||||
|
||||
writer = writers[0]
|
||||
|
||||
logger.debug("Found writer: %s (%s)", writer.__name__, writer.__module__)
|
||||
return writer(self.output_path, settings=self.settings)
|
||||
|
||||
|
||||
class PrintSettings(argparse.Action):
|
||||
def __call__(self, parser, namespace, values, option_string):
|
||||
init_logging(name=__name__)
|
||||
|
||||
try:
|
||||
instance, settings = get_instance(namespace)
|
||||
except Exception as e:
|
||||
logger.critical("%s: %s", e.__class__.__name__, e)
|
||||
console.print_exception()
|
||||
sys.exit(getattr(e, "exitcode", 1))
|
||||
|
||||
if values:
|
||||
# One or more arguments provided, so only print those settings
|
||||
for setting in values:
|
||||
if setting in settings:
|
||||
# Only add newline between setting name and value if dict
|
||||
if isinstance(settings[setting], (dict, tuple, list)):
|
||||
setting_format = "\n{}:\n{}"
|
||||
else:
|
||||
setting_format = "\n{}: {}"
|
||||
console.print(
|
||||
setting_format.format(
|
||||
setting, pprint.pformat(settings[setting])
|
||||
)
|
||||
)
|
||||
else:
|
||||
console.print(f"\n{setting} is not a recognized setting.")
|
||||
break
|
||||
else:
|
||||
# No argument was given to --print-settings, so print all settings
|
||||
console.print(settings)
|
||||
|
||||
parser.exit()
|
||||
|
||||
|
||||
class ParseOverrides(argparse.Action):
|
||||
def __call__(self, parser, namespace, values, option_string=None):
|
||||
overrides = {}
|
||||
for item in values:
|
||||
try:
|
||||
k, v = item.split("=", 1)
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
"Extra settings must be specified as KEY=VALUE pairs "
|
||||
f"but you specified {item}"
|
||||
) from None
|
||||
try:
|
||||
overrides[k] = json.loads(v)
|
||||
except json.decoder.JSONDecodeError:
|
||||
raise ValueError(
|
||||
f"Invalid JSON value: {v}. "
|
||||
"Values specified via -e / --extra-settings flags "
|
||||
"must be in JSON notation. "
|
||||
"Use -e KEY='\"string\"' to specify a string value; "
|
||||
"-e KEY=null to specify None; "
|
||||
"-e KEY=false (or true) to specify False (or True)."
|
||||
) from None
|
||||
setattr(namespace, self.dest, overrides)
|
||||
|
||||
|
||||
def parse_arguments(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description="A tool to generate a static blog, "
|
||||
" with restructured text input files.",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
dest="path",
|
||||
nargs="?",
|
||||
help="Path where to find the content files.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-t",
|
||||
"--theme-path",
|
||||
dest="theme",
|
||||
help="Path where to find the theme templates. If not "
|
||||
"specified, it will use the default one included with "
|
||||
"pelican.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
dest="output",
|
||||
help="Where to output the generated files. If not "
|
||||
"specified, a directory will be created, named "
|
||||
'"output" in the current path.',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--settings",
|
||||
dest="settings",
|
||||
help="The settings of the application, this is "
|
||||
f"automatically set to {DEFAULT_CONFIG_NAME} if a file exists with this "
|
||||
"name.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-d",
|
||||
"--delete-output-directory",
|
||||
dest="delete_outputdir",
|
||||
action="store_true",
|
||||
default=None,
|
||||
help="Delete the output directory.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_const",
|
||||
const=logging.INFO,
|
||||
dest="verbosity",
|
||||
help="Show all messages.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-q",
|
||||
"--quiet",
|
||||
action="store_const",
|
||||
const=logging.CRITICAL,
|
||||
dest="verbosity",
|
||||
help="Show only critical errors.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-D",
|
||||
"--debug",
|
||||
action="store_const",
|
||||
const=logging.DEBUG,
|
||||
dest="verbosity",
|
||||
help="Show all messages, including debug messages.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=__version__,
|
||||
help="Print the pelican version and exit.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--autoreload",
|
||||
dest="autoreload",
|
||||
action="store_true",
|
||||
help="Relaunch pelican each time a modification occurs"
|
||||
" on the content files.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--print-settings",
|
||||
dest="print_settings",
|
||||
nargs="*",
|
||||
action=PrintSettings,
|
||||
metavar="SETTING_NAME",
|
||||
help="Print current configuration settings and exit. "
|
||||
"Append one or more setting name arguments to see the "
|
||||
"values for specific settings only.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--relative-urls",
|
||||
dest="relative_paths",
|
||||
action="store_true",
|
||||
help="Use relative urls in output, useful for site development",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--cache-path",
|
||||
dest="cache_path",
|
||||
help=(
|
||||
"Directory in which to store cache files. "
|
||||
'If not specified, defaults to "cache".'
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--ignore-cache",
|
||||
action="store_true",
|
||||
dest="ignore_cache",
|
||||
help="Ignore content cache from previous runs by not loading cache files.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--fatal",
|
||||
metavar="errors|warnings",
|
||||
choices=("errors", "warnings"),
|
||||
default="",
|
||||
help=(
|
||||
"Exit the program with non-zero status if any "
|
||||
"errors/warnings encountered."
|
||||
),
|
||||
)
|
||||
|
||||
LOG_HANDLERS = {"plain": None, "rich": DEFAULT_LOG_HANDLER}
|
||||
parser.add_argument(
|
||||
"--log-handler",
|
||||
default="rich",
|
||||
choices=LOG_HANDLERS,
|
||||
help=(
|
||||
"Which handler to use to format log messages. "
|
||||
"The `rich` handler prints output in columns."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--logs-dedup-min-level",
|
||||
default="WARNING",
|
||||
choices=("DEBUG", "INFO", "WARNING", "ERROR"),
|
||||
help=(
|
||||
"Only enable log de-duplication for levels equal"
|
||||
" to or above the specified value"
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
dest="listen",
|
||||
action="store_true",
|
||||
help="Serve content files via HTTP and port 8000.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--port",
|
||||
dest="port",
|
||||
type=int,
|
||||
help="Port to serve HTTP files at. (default: 8000)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-b",
|
||||
"--bind",
|
||||
dest="bind",
|
||||
help="IP to bind to when serving files via HTTP (default: 127.0.0.1)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-e",
|
||||
"--extra-settings",
|
||||
dest="overrides",
|
||||
help="Specify one or more SETTING=VALUE pairs to "
|
||||
"override settings. VALUE must be in JSON notation: "
|
||||
"specify string values as SETTING='\"some string\"'; "
|
||||
"booleans as SETTING=true or SETTING=false; "
|
||||
"None as SETTING=null.",
|
||||
nargs="*",
|
||||
action=ParseOverrides,
|
||||
default={},
|
||||
)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.port is not None and not args.listen:
|
||||
logger.warning("--port without --listen has no effect")
|
||||
if args.bind is not None and not args.listen:
|
||||
logger.warning("--bind without --listen has no effect")
|
||||
|
||||
args.log_handler = LOG_HANDLERS[args.log_handler]
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def get_config(args):
|
||||
"""Builds a config dictionary based on supplied `args`."""
|
||||
config = {}
|
||||
if args.path:
|
||||
config["PATH"] = os.path.abspath(os.path.expanduser(args.path))
|
||||
if args.output:
|
||||
config["OUTPUT_PATH"] = os.path.abspath(os.path.expanduser(args.output))
|
||||
if args.theme:
|
||||
abstheme = os.path.abspath(os.path.expanduser(args.theme))
|
||||
config["THEME"] = abstheme if os.path.exists(abstheme) else args.theme
|
||||
if args.delete_outputdir is not None:
|
||||
config["DELETE_OUTPUT_DIRECTORY"] = args.delete_outputdir
|
||||
if args.ignore_cache:
|
||||
config["LOAD_CONTENT_CACHE"] = False
|
||||
if args.cache_path:
|
||||
config["CACHE_PATH"] = args.cache_path
|
||||
if args.relative_paths:
|
||||
config["RELATIVE_URLS"] = args.relative_paths
|
||||
if args.port is not None:
|
||||
config["PORT"] = args.port
|
||||
if args.bind is not None:
|
||||
config["BIND"] = args.bind
|
||||
config["DEBUG"] = args.verbosity == logging.DEBUG
|
||||
config.update(args.overrides)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def get_instance(args):
|
||||
config_file = args.settings
|
||||
if config_file is None and os.path.isfile(DEFAULT_CONFIG_NAME):
|
||||
config_file = DEFAULT_CONFIG_NAME
|
||||
args.settings = DEFAULT_CONFIG_NAME
|
||||
|
||||
settings = read_settings(config_file, override=get_config(args))
|
||||
|
||||
cls = settings["PELICAN_CLASS"]
|
||||
if isinstance(cls, str):
|
||||
module, cls_name = cls.rsplit(".", 1)
|
||||
module = __import__(module)
|
||||
cls = getattr(module, cls_name)
|
||||
|
||||
return cls(settings), settings
|
||||
|
||||
|
||||
def autoreload(args, excqueue=None):
|
||||
console.print(
|
||||
" --- AutoReload Mode: Monitoring `content`, `theme` and"
|
||||
" `settings` for changes. ---"
|
||||
)
|
||||
pelican, settings = get_instance(args)
|
||||
settings_file = os.path.abspath(args.settings)
|
||||
while True:
|
||||
try:
|
||||
pelican.run()
|
||||
|
||||
changed_files = wait_for_changes(args.settings, settings)
|
||||
changed_files = {c[1] for c in changed_files}
|
||||
|
||||
if settings_file in changed_files:
|
||||
pelican, settings = get_instance(args)
|
||||
|
||||
console.print(
|
||||
"\n-> Modified: {}. re-generating...".format(", ".join(changed_files))
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
if excqueue is not None:
|
||||
excqueue.put(None)
|
||||
return
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if args.verbosity == logging.DEBUG:
|
||||
if excqueue is not None:
|
||||
excqueue.put(traceback.format_exception_only(type(e), e)[-1])
|
||||
else:
|
||||
raise
|
||||
logger.warning(
|
||||
'Caught exception:\n"%s".', e, exc_info=settings.get("DEBUG", False)
|
||||
)
|
||||
|
||||
|
||||
def listen(server, port, output, excqueue=None):
|
||||
# set logging level to at least "INFO" (so we can see the server requests)
|
||||
if logger.level < logging.INFO:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
RootedHTTPServer.allow_reuse_address = True
|
||||
try:
|
||||
httpd = RootedHTTPServer(output, (server, port), ComplexHTTPRequestHandler)
|
||||
except OSError as e:
|
||||
logging.error("Could not listen on port %s, server %s.", port, server)
|
||||
if excqueue is not None:
|
||||
excqueue.put(traceback.format_exception_only(type(e), e)[-1])
|
||||
return
|
||||
|
||||
try:
|
||||
console.print(f"Serving site at: http://{server}:{port} - Tap CTRL-C to stop")
|
||||
httpd.serve_forever()
|
||||
except Exception as e:
|
||||
if excqueue is not None:
|
||||
excqueue.put(traceback.format_exception_only(type(e), e)[-1])
|
||||
return
|
||||
|
||||
except KeyboardInterrupt:
|
||||
httpd.socket.close()
|
||||
if excqueue is not None:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
args = parse_arguments(argv)
|
||||
logs_dedup_min_level = getattr(logging, args.logs_dedup_min_level)
|
||||
init_logging(
|
||||
level=args.verbosity,
|
||||
fatal=args.fatal,
|
||||
name=__name__,
|
||||
handler=args.log_handler,
|
||||
logs_dedup_min_level=logs_dedup_min_level,
|
||||
)
|
||||
|
||||
logger.debug("Pelican version: %s", __version__)
|
||||
logger.debug("Python version: %s", sys.version.split()[0])
|
||||
|
||||
try:
|
||||
pelican, settings = get_instance(args)
|
||||
|
||||
if args.autoreload and args.listen:
|
||||
excqueue = multiprocessing.Queue()
|
||||
p1 = multiprocessing.Process(target=autoreload, args=(args, excqueue))
|
||||
p2 = multiprocessing.Process(
|
||||
target=listen,
|
||||
args=(
|
||||
settings.get("BIND"),
|
||||
settings.get("PORT"),
|
||||
settings.get("OUTPUT_PATH"),
|
||||
excqueue,
|
||||
),
|
||||
)
|
||||
try:
|
||||
p1.start()
|
||||
p2.start()
|
||||
exc = excqueue.get()
|
||||
if exc is not None:
|
||||
logger.critical(exc)
|
||||
finally:
|
||||
p1.terminate()
|
||||
p2.terminate()
|
||||
elif args.autoreload:
|
||||
autoreload(args)
|
||||
elif args.listen:
|
||||
listen(
|
||||
settings.get("BIND"), settings.get("PORT"), settings.get("OUTPUT_PATH")
|
||||
)
|
||||
else:
|
||||
with console.status("Generating..."):
|
||||
pelican.run()
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("Keyboard interrupt received. Exiting.")
|
||||
except Exception as e:
|
||||
logger.critical("%s: %s", e.__class__.__name__, e)
|
||||
|
||||
if args.verbosity == logging.DEBUG:
|
||||
console.print_exception()
|
||||
sys.exit(getattr(e, "exitcode", 1))
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
"""
|
||||
python -m pelican module entry point to run via python -m
|
||||
"""
|
||||
|
||||
from . import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
140
pelican/cache.py
140
pelican/cache.py
|
|
@ -1,140 +0,0 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
|
||||
from pelican.utils import mkdir_p
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileDataCacher:
|
||||
"""Class that can cache data contained in files"""
|
||||
|
||||
def __init__(self, settings, cache_name, caching_policy, load_policy):
|
||||
"""Load the specified cache within CACHE_PATH in settings
|
||||
|
||||
only if *load_policy* is True,
|
||||
May use gzip if GZIP_CACHE ins settings is True.
|
||||
Sets caching policy according to *caching_policy*.
|
||||
"""
|
||||
self.settings = settings
|
||||
self._cache_path = os.path.join(self.settings["CACHE_PATH"], cache_name)
|
||||
self._cache_data_policy = caching_policy
|
||||
if self.settings["GZIP_CACHE"]:
|
||||
import gzip
|
||||
|
||||
self._cache_open = gzip.open
|
||||
else:
|
||||
self._cache_open = open
|
||||
if load_policy:
|
||||
try:
|
||||
with self._cache_open(self._cache_path, "rb") as fhandle:
|
||||
self._cache = pickle.load(fhandle)
|
||||
except (OSError, UnicodeDecodeError) as err:
|
||||
logger.debug(
|
||||
"Cannot load cache %s (this is normal on first "
|
||||
"run). Proceeding with empty cache.\n%s",
|
||||
self._cache_path,
|
||||
err,
|
||||
)
|
||||
self._cache = {}
|
||||
except pickle.PickleError as err:
|
||||
logger.warning(
|
||||
"Cannot unpickle cache %s, cache may be using "
|
||||
"an incompatible protocol (see pelican "
|
||||
"caching docs). "
|
||||
"Proceeding with empty cache.\n%s",
|
||||
self._cache_path,
|
||||
err,
|
||||
)
|
||||
self._cache = {}
|
||||
else:
|
||||
self._cache = {}
|
||||
|
||||
def cache_data(self, filename, data):
|
||||
"""Cache data for given file"""
|
||||
if self._cache_data_policy:
|
||||
self._cache[filename] = data
|
||||
|
||||
def get_cached_data(self, filename, default=None):
|
||||
"""Get cached data for the given file
|
||||
|
||||
if no data is cached, return the default object
|
||||
"""
|
||||
return self._cache.get(filename, default)
|
||||
|
||||
def save_cache(self):
|
||||
"""Save the updated cache"""
|
||||
if self._cache_data_policy:
|
||||
try:
|
||||
mkdir_p(self.settings["CACHE_PATH"])
|
||||
with self._cache_open(self._cache_path, "wb") as fhandle:
|
||||
pickle.dump(self._cache, fhandle)
|
||||
except (OSError, pickle.PicklingError, TypeError) as err:
|
||||
logger.warning(
|
||||
"Could not save cache %s\n ... %s", self._cache_path, err
|
||||
)
|
||||
|
||||
|
||||
class FileStampDataCacher(FileDataCacher):
|
||||
"""Subclass that also caches the stamp of the file"""
|
||||
|
||||
def __init__(self, settings, cache_name, caching_policy, load_policy):
|
||||
"""This subclass additionally sets filestamp function
|
||||
and base path for filestamping operations
|
||||
"""
|
||||
|
||||
super().__init__(settings, cache_name, caching_policy, load_policy)
|
||||
|
||||
method = self.settings["CHECK_MODIFIED_METHOD"]
|
||||
if method == "mtime":
|
||||
self._filestamp_func = os.path.getmtime
|
||||
else:
|
||||
try:
|
||||
hash_func = getattr(hashlib, method)
|
||||
|
||||
def filestamp_func(filename):
|
||||
"""return hash of file contents"""
|
||||
with open(filename, "rb") as fhandle:
|
||||
return hash_func(fhandle.read()).digest()
|
||||
|
||||
self._filestamp_func = filestamp_func
|
||||
except AttributeError as err:
|
||||
logger.warning("Could not get hashing function\n\t%s", err)
|
||||
self._filestamp_func = None
|
||||
|
||||
def cache_data(self, filename, data):
|
||||
"""Cache stamp and data for the given file"""
|
||||
stamp = self._get_file_stamp(filename)
|
||||
super().cache_data(filename, (stamp, data))
|
||||
|
||||
def _get_file_stamp(self, filename):
|
||||
"""Check if the given file has been modified
|
||||
since the previous build.
|
||||
|
||||
depending on CHECK_MODIFIED_METHOD
|
||||
a float may be returned for 'mtime',
|
||||
a hash for a function name in the hashlib module
|
||||
or an empty bytes string otherwise
|
||||
"""
|
||||
|
||||
try:
|
||||
return self._filestamp_func(filename)
|
||||
except (OSError, TypeError) as err:
|
||||
logger.warning("Cannot get modification stamp for %s\n\t%s", filename, err)
|
||||
return ""
|
||||
|
||||
def get_cached_data(self, filename, default=None):
|
||||
"""Get the cached data for the given filename
|
||||
if the file has not been modified.
|
||||
|
||||
If no record exists or file has been modified, return default.
|
||||
Modification is checked by comparing the cached
|
||||
and current file stamp.
|
||||
"""
|
||||
|
||||
stamp, data = super().get_cached_data(filename, (None, default))
|
||||
if stamp != self._get_file_stamp(filename):
|
||||
return default
|
||||
return data
|
||||
|
|
@ -1,692 +0,0 @@
|
|||
import copy
|
||||
import datetime
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import timezone
|
||||
from html import unescape
|
||||
from typing import Any, Dict, Optional, Set, Tuple
|
||||
from urllib.parse import ParseResult, unquote, urljoin, urlparse, urlunparse
|
||||
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
except ModuleNotFoundError:
|
||||
from backports.zoneinfo import ZoneInfo
|
||||
|
||||
|
||||
from pelican.plugins import signals
|
||||
from pelican.settings import DEFAULT_CONFIG, Settings
|
||||
|
||||
# Import these so that they're available when you import from pelican.contents.
|
||||
from pelican.urlwrappers import Author, Category, Tag, URLWrapper # NOQA
|
||||
from pelican.utils import (
|
||||
deprecated_attribute,
|
||||
memoized,
|
||||
path_to_url,
|
||||
posixize_path,
|
||||
sanitised_join,
|
||||
set_date_tzinfo,
|
||||
slugify,
|
||||
truncate_html_paragraphs,
|
||||
truncate_html_words,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Content:
|
||||
"""Represents a content.
|
||||
|
||||
:param content: the string to parse, containing the original content.
|
||||
:param metadata: the metadata associated to this page (optional).
|
||||
:param settings: the settings dictionary (optional).
|
||||
:param source_path: The location of the source of this content (if any).
|
||||
:param context: The shared context between generators.
|
||||
|
||||
"""
|
||||
|
||||
default_template: Optional[str] = None
|
||||
mandatory_properties: Tuple[str, ...] = ()
|
||||
|
||||
@deprecated_attribute(old="filename", new="source_path", since=(3, 2, 0))
|
||||
def filename():
|
||||
return None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
settings: Optional[Settings] = None,
|
||||
source_path: Optional[str] = None,
|
||||
context: Optional[Dict[Any, Any]] = None,
|
||||
):
|
||||
if metadata is None:
|
||||
metadata = {}
|
||||
if settings is None:
|
||||
settings = copy.deepcopy(DEFAULT_CONFIG)
|
||||
|
||||
self.settings = settings
|
||||
self._content = content
|
||||
if context is None:
|
||||
context = {}
|
||||
self._context = context
|
||||
self.translations = []
|
||||
|
||||
local_metadata = {}
|
||||
local_metadata.update(metadata)
|
||||
|
||||
# set metadata as attributes
|
||||
for key, value in local_metadata.items():
|
||||
if key in ("save_as", "url"):
|
||||
key = "override_" + key
|
||||
setattr(self, key.lower(), value)
|
||||
|
||||
# also keep track of the metadata attributes available
|
||||
self.metadata = local_metadata
|
||||
|
||||
# default template if it's not defined in page
|
||||
self.template = self._get_template()
|
||||
|
||||
# First, read the authors from "authors", if not, fallback to "author"
|
||||
# and if not use the settings defined one, if any.
|
||||
if not hasattr(self, "author"):
|
||||
if hasattr(self, "authors"):
|
||||
self.author = self.authors[0]
|
||||
elif "AUTHOR" in settings:
|
||||
self.author = Author(settings["AUTHOR"], settings)
|
||||
|
||||
if not hasattr(self, "authors") and hasattr(self, "author"):
|
||||
self.authors = [self.author]
|
||||
|
||||
# XXX Split all the following code into pieces, there is too much here.
|
||||
|
||||
# manage languages
|
||||
self.in_default_lang = True
|
||||
if "DEFAULT_LANG" in settings:
|
||||
default_lang = settings["DEFAULT_LANG"].lower()
|
||||
if not hasattr(self, "lang"):
|
||||
self.lang = default_lang
|
||||
|
||||
self.in_default_lang = self.lang == default_lang
|
||||
|
||||
# create the slug if not existing, generate slug according to
|
||||
# setting of SLUG_ATTRIBUTE
|
||||
if not hasattr(self, "slug"):
|
||||
if settings["SLUGIFY_SOURCE"] == "title" and hasattr(self, "title"):
|
||||
value = self.title
|
||||
elif settings["SLUGIFY_SOURCE"] == "basename" and source_path is not None:
|
||||
value = os.path.basename(os.path.splitext(source_path)[0])
|
||||
else:
|
||||
value = None
|
||||
if value is not None:
|
||||
self.slug = slugify(
|
||||
value,
|
||||
regex_subs=settings.get("SLUG_REGEX_SUBSTITUTIONS", []),
|
||||
preserve_case=settings.get("SLUGIFY_PRESERVE_CASE", False),
|
||||
use_unicode=settings.get("SLUGIFY_USE_UNICODE", False),
|
||||
)
|
||||
|
||||
self.source_path = source_path
|
||||
self.relative_source_path = self.get_relative_source_path()
|
||||
|
||||
# manage the date format
|
||||
if not hasattr(self, "date_format"):
|
||||
if hasattr(self, "lang") and self.lang in settings["DATE_FORMATS"]:
|
||||
self.date_format = settings["DATE_FORMATS"][self.lang]
|
||||
else:
|
||||
self.date_format = settings["DEFAULT_DATE_FORMAT"]
|
||||
|
||||
if isinstance(self.date_format, tuple):
|
||||
locale_string = self.date_format[0]
|
||||
locale.setlocale(locale.LC_ALL, locale_string)
|
||||
self.date_format = self.date_format[1]
|
||||
|
||||
# manage timezone
|
||||
default_timezone = settings.get("TIMEZONE", "UTC")
|
||||
timezone = getattr(self, "timezone", default_timezone)
|
||||
self.timezone = ZoneInfo(timezone)
|
||||
|
||||
if hasattr(self, "date"):
|
||||
self.date = set_date_tzinfo(self.date, timezone)
|
||||
self.locale_date = self.date.strftime(self.date_format)
|
||||
|
||||
if hasattr(self, "modified"):
|
||||
self.modified = set_date_tzinfo(self.modified, timezone)
|
||||
self.locale_modified = self.modified.strftime(self.date_format)
|
||||
|
||||
# manage status
|
||||
if not hasattr(self, "status"):
|
||||
# Previous default of None broke comment plugins and perhaps others
|
||||
self.status = getattr(self, "default_status", "")
|
||||
|
||||
# store the summary metadata if it is set
|
||||
if "summary" in metadata:
|
||||
self._summary = metadata["summary"]
|
||||
|
||||
signals.content_object_init.send(self)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.source_path or repr(self)
|
||||
|
||||
def _has_valid_mandatory_properties(self) -> bool:
|
||||
"""Test mandatory properties are set."""
|
||||
for prop in self.mandatory_properties:
|
||||
if not hasattr(self, prop):
|
||||
logger.error(
|
||||
"Skipping %s: could not find information about '%s'", self, prop
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _has_valid_save_as(self) -> bool:
|
||||
"""Return true if save_as doesn't write outside output path, false
|
||||
otherwise."""
|
||||
try:
|
||||
output_path = self.settings["OUTPUT_PATH"]
|
||||
except KeyError:
|
||||
# we cannot check
|
||||
return True
|
||||
|
||||
try:
|
||||
sanitised_join(output_path, self.save_as)
|
||||
except RuntimeError: # outside output_dir
|
||||
logger.error(
|
||||
"Skipping %s: file %r would be written outside output path",
|
||||
self,
|
||||
self.save_as,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _has_valid_status(self) -> bool:
|
||||
if hasattr(self, "allowed_statuses"):
|
||||
if self.status not in self.allowed_statuses:
|
||||
logger.error(
|
||||
"Unknown status '%s' for file %s, skipping it. (Not in %s)",
|
||||
self.status,
|
||||
self,
|
||||
self.allowed_statuses,
|
||||
)
|
||||
return False
|
||||
|
||||
# if undefined we allow all
|
||||
return True
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Validate Content"""
|
||||
# Use all() to not short circuit and get results of all validations
|
||||
return all(
|
||||
[
|
||||
self._has_valid_mandatory_properties(),
|
||||
self._has_valid_save_as(),
|
||||
self._has_valid_status(),
|
||||
]
|
||||
)
|
||||
|
||||
@property
|
||||
def url_format(self) -> Dict[str, Any]:
|
||||
"""Returns the URL, formatted with the proper values"""
|
||||
metadata = copy.copy(self.metadata)
|
||||
path = self.metadata.get("path", self.get_relative_source_path())
|
||||
metadata.update(
|
||||
{
|
||||
"path": path_to_url(path),
|
||||
"slug": getattr(self, "slug", ""),
|
||||
"lang": getattr(self, "lang", "en"),
|
||||
"date": getattr(self, "date", datetime.datetime.now()),
|
||||
"author": self.author.slug if hasattr(self, "author") else "",
|
||||
"category": self.category.slug if hasattr(self, "category") else "",
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
def _expand_settings(self, key: str, klass: Optional[str] = None) -> str:
|
||||
if not klass:
|
||||
klass = self.__class__.__name__
|
||||
fq_key = (f"{klass}_{key}").upper()
|
||||
return str(self.settings[fq_key]).format(**self.url_format)
|
||||
|
||||
def get_url_setting(self, key: str) -> str:
|
||||
if hasattr(self, "override_" + key):
|
||||
return getattr(self, "override_" + key)
|
||||
key = key if self.in_default_lang else f"lang_{key}"
|
||||
return self._expand_settings(key)
|
||||
|
||||
def _link_replacer(self, siteurl: str, m: re.Match) -> str:
|
||||
what = m.group("what")
|
||||
value = urlparse(m.group("value"))
|
||||
path = value.path
|
||||
origin = m.group("path")
|
||||
|
||||
# urllib.parse.urljoin() produces `a.html` for urljoin("..", "a.html")
|
||||
# so if RELATIVE_URLS are enabled, we fall back to os.path.join() to
|
||||
# properly get `../a.html`. However, os.path.join() produces
|
||||
# `baz/http://foo/bar.html` for join("baz", "http://foo/bar.html")
|
||||
# instead of correct "http://foo/bar.html", so one has to pick a side
|
||||
# as there is no silver bullet.
|
||||
if self.settings["RELATIVE_URLS"]:
|
||||
joiner = os.path.join
|
||||
else:
|
||||
joiner = urljoin
|
||||
|
||||
# However, it's not *that* simple: urljoin("blog", "index.html")
|
||||
# produces just `index.html` instead of `blog/index.html` (unlike
|
||||
# os.path.join()), so in order to get a correct answer one needs to
|
||||
# append a trailing slash to siteurl in that case. This also makes
|
||||
# the new behavior fully compatible with Pelican 3.7.1.
|
||||
if not siteurl.endswith("/"):
|
||||
siteurl += "/"
|
||||
|
||||
# XXX Put this in a different location.
|
||||
if what in {"filename", "static", "attach"}:
|
||||
|
||||
def _get_linked_content(key: str, url: ParseResult) -> Optional[Content]:
|
||||
nonlocal value
|
||||
|
||||
def _find_path(path: str) -> Optional[Content]:
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
else:
|
||||
# relative to the source path of this content
|
||||
path = self.get_relative_source_path( # type: ignore
|
||||
os.path.join(self.relative_dir, path)
|
||||
)
|
||||
return self._context[key].get(path, None)
|
||||
|
||||
# try path
|
||||
result = _find_path(url.path)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# try unquoted path
|
||||
result = _find_path(unquote(url.path))
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# try html unescaped url
|
||||
unescaped_url = urlparse(unescape(url.geturl()))
|
||||
result = _find_path(unescaped_url.path)
|
||||
if result is not None:
|
||||
value = unescaped_url
|
||||
return result
|
||||
|
||||
# check if a static file is linked with {filename}
|
||||
if what == "filename" and key == "generated_content":
|
||||
linked_content = _get_linked_content("static_content", value)
|
||||
if linked_content:
|
||||
logger.warning(
|
||||
"{filename} used for linking to static"
|
||||
" content %s in %s. Use {static} instead",
|
||||
value.path,
|
||||
self.get_relative_source_path(),
|
||||
)
|
||||
return linked_content
|
||||
|
||||
return None
|
||||
|
||||
if what == "filename":
|
||||
key = "generated_content"
|
||||
else:
|
||||
key = "static_content"
|
||||
|
||||
linked_content = _get_linked_content(key, value)
|
||||
if linked_content:
|
||||
if what == "attach":
|
||||
linked_content.attach_to(self) # type: ignore
|
||||
origin = joiner(siteurl, linked_content.url)
|
||||
origin = origin.replace("\\", "/") # for Windows paths.
|
||||
else:
|
||||
logger.warning(
|
||||
"Unable to find '%s', skipping url replacement.",
|
||||
value.geturl(),
|
||||
extra={
|
||||
"limit_msg": (
|
||||
"Other resources were not found "
|
||||
"and their urls not replaced"
|
||||
)
|
||||
},
|
||||
)
|
||||
elif what == "category":
|
||||
origin = joiner(siteurl, Category(path, self.settings).url)
|
||||
elif what == "tag":
|
||||
origin = joiner(siteurl, Tag(path, self.settings).url)
|
||||
elif what == "index":
|
||||
origin = joiner(siteurl, self.settings["INDEX_SAVE_AS"])
|
||||
elif what == "author":
|
||||
origin = joiner(siteurl, Author(path, self.settings).url)
|
||||
else:
|
||||
logger.warning(
|
||||
"Replacement Indicator '%s' not recognized, skipping replacement",
|
||||
what,
|
||||
)
|
||||
|
||||
# keep all other parts, such as query, fragment, etc.
|
||||
parts = list(value)
|
||||
parts[2] = origin
|
||||
origin = urlunparse(parts)
|
||||
|
||||
return "".join((m.group("markup"), m.group("quote"), origin, m.group("quote")))
|
||||
|
||||
def _get_intrasite_link_regex(self) -> re.Pattern:
|
||||
intrasite_link_regex = self.settings["INTRASITE_LINK_REGEX"]
|
||||
regex = rf"""
|
||||
(?P<markup><[^\>]+ # match tag with all url-value attributes
|
||||
(?:href|src|poster|data|cite|formaction|action|content)\s*=\s*)
|
||||
|
||||
(?P<quote>["\']) # require value to be quoted
|
||||
(?P<path>{intrasite_link_regex}(?P<value>.*?)) # the url value
|
||||
(?P=quote)"""
|
||||
return re.compile(regex, re.X)
|
||||
|
||||
def _update_content(self, content: str, siteurl: str) -> str:
|
||||
"""Update the content attribute.
|
||||
|
||||
Change all the relative paths of the content to relative paths
|
||||
suitable for the output content.
|
||||
|
||||
:param content: content resource that will be passed to the templates.
|
||||
:param siteurl: siteurl which is locally generated by the writer in
|
||||
case of RELATIVE_URLS.
|
||||
"""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
hrefs = self._get_intrasite_link_regex()
|
||||
return hrefs.sub(lambda m: self._link_replacer(siteurl, m), content)
|
||||
|
||||
def get_static_links(self) -> Set[str]:
|
||||
static_links = set()
|
||||
hrefs = self._get_intrasite_link_regex()
|
||||
for m in hrefs.finditer(self._content):
|
||||
what = m.group("what")
|
||||
value = urlparse(m.group("value"))
|
||||
path = value.path
|
||||
if what not in {"static", "attach"}:
|
||||
continue
|
||||
if path.startswith("/"):
|
||||
path = path[1:]
|
||||
else:
|
||||
# relative to the source path of this content
|
||||
path = self.get_relative_source_path(
|
||||
os.path.join(self.relative_dir, path)
|
||||
)
|
||||
path = path.replace("%20", " ") # type: ignore
|
||||
static_links.add(path)
|
||||
return static_links
|
||||
|
||||
def get_siteurl(self) -> str:
|
||||
return self._context.get("localsiteurl", "")
|
||||
|
||||
@memoized
|
||||
def get_content(self, siteurl: str) -> str:
|
||||
if hasattr(self, "_get_content"):
|
||||
content = self._get_content()
|
||||
else:
|
||||
content = self._content
|
||||
return self._update_content(content, siteurl)
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
return self.get_content(self.get_siteurl())
|
||||
|
||||
@memoized
|
||||
def get_summary(self, siteurl: str) -> str:
|
||||
"""Returns the summary of an article.
|
||||
|
||||
This is based on the summary metadata if set, otherwise truncate the
|
||||
content.
|
||||
"""
|
||||
if "summary" in self.metadata:
|
||||
return self.metadata["summary"]
|
||||
|
||||
content = self.content
|
||||
max_paragraphs = self.settings.get("SUMMARY_MAX_PARAGRAPHS")
|
||||
if max_paragraphs is not None:
|
||||
content = truncate_html_paragraphs(self.content, max_paragraphs)
|
||||
|
||||
if self.settings["SUMMARY_MAX_LENGTH"] is None:
|
||||
return content
|
||||
|
||||
return truncate_html_words(
|
||||
self.content,
|
||||
self.settings["SUMMARY_MAX_LENGTH"],
|
||||
self.settings["SUMMARY_END_SUFFIX"],
|
||||
)
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
return self.get_summary(self.get_siteurl())
|
||||
|
||||
def _get_summary(self) -> str:
|
||||
"""deprecated function to access summary"""
|
||||
|
||||
logger.warning(
|
||||
"_get_summary() has been deprecated since 3.6.4. "
|
||||
"Use the summary decorator instead"
|
||||
)
|
||||
return self.summary
|
||||
|
||||
@summary.setter
|
||||
def summary(self, value: str):
|
||||
"""Dummy function"""
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, value: str) -> None:
|
||||
# TODO maybe typecheck
|
||||
self._status = value.lower()
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return self.get_url_setting("url")
|
||||
|
||||
@property
|
||||
def save_as(self) -> str:
|
||||
return self.get_url_setting("save_as")
|
||||
|
||||
def _get_template(self) -> str:
|
||||
if hasattr(self, "template") and self.template is not None:
|
||||
return self.template
|
||||
else:
|
||||
return self.default_template
|
||||
|
||||
def get_relative_source_path(
|
||||
self, source_path: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Return the relative path (from the content path) to the given
|
||||
source_path.
|
||||
|
||||
If no source path is specified, use the source path of this
|
||||
content object.
|
||||
"""
|
||||
if not source_path:
|
||||
source_path = self.source_path
|
||||
if source_path is None:
|
||||
return None
|
||||
|
||||
return posixize_path(
|
||||
os.path.relpath(
|
||||
os.path.abspath(os.path.join(self.settings["PATH"], source_path)),
|
||||
os.path.abspath(self.settings["PATH"]),
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def relative_dir(self) -> str:
|
||||
return posixize_path(
|
||||
os.path.dirname(
|
||||
os.path.relpath(
|
||||
os.path.abspath(self.source_path),
|
||||
os.path.abspath(self.settings["PATH"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def refresh_metadata_intersite_links(self) -> None:
|
||||
for key in self.settings["FORMATTED_FIELDS"]:
|
||||
if key in self.metadata and key != "summary":
|
||||
value = self._update_content(self.metadata[key], self.get_siteurl())
|
||||
self.metadata[key] = value
|
||||
setattr(self, key.lower(), value)
|
||||
|
||||
# _summary is an internal variable that some plugins may be writing to,
|
||||
# so ensure changes to it are picked up, and write summary back to it
|
||||
if "summary" in self.settings["FORMATTED_FIELDS"]:
|
||||
if hasattr(self, "_summary"):
|
||||
self.metadata["summary"] = self._summary
|
||||
|
||||
if "summary" in self.metadata:
|
||||
self.metadata["summary"] = self._update_content(
|
||||
self.metadata["summary"], self.get_siteurl()
|
||||
)
|
||||
self._summary = self.metadata["summary"]
|
||||
|
||||
|
||||
class SkipStub(Content):
|
||||
"""Stub class representing content that should not be processed in any way."""
|
||||
|
||||
def __init__(
|
||||
self, content, metadata=None, settings=None, source_path=None, context=None
|
||||
):
|
||||
self.source_path = source_path
|
||||
|
||||
def is_valid(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def content(self):
|
||||
raise NotImplementedError("Stub content should not be read")
|
||||
|
||||
@property
|
||||
def save_as(self):
|
||||
raise NotImplementedError("Stub content cannot be saved")
|
||||
|
||||
|
||||
class Page(Content):
|
||||
mandatory_properties = ("title",)
|
||||
allowed_statuses = ("published", "hidden", "draft", "skip")
|
||||
default_status = "published"
|
||||
default_template = "page"
|
||||
|
||||
def _expand_settings(self, key: str) -> str:
|
||||
klass = "draft_page" if self.status == "draft" else None
|
||||
return super()._expand_settings(key, klass)
|
||||
|
||||
|
||||
class Article(Content):
|
||||
mandatory_properties = ("title", "date", "category")
|
||||
allowed_statuses = ("published", "hidden", "draft", "skip")
|
||||
default_status = "published"
|
||||
default_template = "article"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# handle WITH_FUTURE_DATES (designate article to draft based on date)
|
||||
if not self.settings["WITH_FUTURE_DATES"] and hasattr(self, "date"):
|
||||
if self.date.tzinfo is None:
|
||||
now = datetime.datetime.now()
|
||||
else:
|
||||
now = datetime.datetime.utcnow().replace(tzinfo=timezone.utc)
|
||||
if self.date > now:
|
||||
self.status = "draft"
|
||||
|
||||
# if we are a draft and there is no date provided, set max datetime
|
||||
if not hasattr(self, "date") and self.status == "draft":
|
||||
self.date = datetime.datetime.max.replace(tzinfo=self.timezone)
|
||||
|
||||
def _expand_settings(self, key: str) -> str:
|
||||
klass = "draft" if self.status == "draft" else "article"
|
||||
return super()._expand_settings(key, klass)
|
||||
|
||||
|
||||
class Static(Content):
|
||||
mandatory_properties = ("title",)
|
||||
default_status = "published"
|
||||
default_template = None
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._output_location_referenced = False
|
||||
|
||||
@deprecated_attribute(old="filepath", new="source_path", since=(3, 2, 0))
|
||||
def filepath():
|
||||
return None
|
||||
|
||||
@deprecated_attribute(old="src", new="source_path", since=(3, 2, 0))
|
||||
def src():
|
||||
return None
|
||||
|
||||
@deprecated_attribute(old="dst", new="save_as", since=(3, 2, 0))
|
||||
def dst():
|
||||
return None
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
# Note when url has been referenced, so we can avoid overriding it.
|
||||
self._output_location_referenced = True
|
||||
return super().url
|
||||
|
||||
@property
|
||||
def save_as(self) -> str:
|
||||
# Note when save_as has been referenced, so we can avoid overriding it.
|
||||
self._output_location_referenced = True
|
||||
return super().save_as
|
||||
|
||||
def attach_to(self, content: Content) -> None:
|
||||
"""Override our output directory with that of the given content object."""
|
||||
|
||||
# Determine our file's new output path relative to the linking
|
||||
# document. If it currently lives beneath the linking
|
||||
# document's source directory, preserve that relationship on output.
|
||||
# Otherwise, make it a sibling.
|
||||
|
||||
linking_source_dir = os.path.dirname(content.source_path)
|
||||
tail_path = os.path.relpath(self.source_path, linking_source_dir)
|
||||
if tail_path.startswith(os.pardir + os.sep):
|
||||
tail_path = os.path.basename(tail_path)
|
||||
new_save_as = os.path.join(os.path.dirname(content.save_as), tail_path)
|
||||
|
||||
# We do not build our new url by joining tail_path with the linking
|
||||
# document's url, because we cannot know just by looking at the latter
|
||||
# whether it points to the document itself or to its parent directory.
|
||||
# (An url like 'some/content' might mean a directory named 'some'
|
||||
# with a file named 'content', or it might mean a directory named
|
||||
# 'some/content' with a file named 'index.html'.) Rather than trying
|
||||
# to figure it out by comparing the linking document's url and save_as
|
||||
# path, we simply build our new url from our new save_as path.
|
||||
|
||||
new_url = path_to_url(new_save_as)
|
||||
|
||||
def _log_reason(reason: str) -> None:
|
||||
logger.warning(
|
||||
"The {attach} link in %s cannot relocate "
|
||||
"%s because %s. Falling back to "
|
||||
"{filename} link behavior instead.",
|
||||
content.get_relative_source_path(),
|
||||
self.get_relative_source_path(),
|
||||
reason,
|
||||
extra={"limit_msg": "More {attach} warnings silenced."},
|
||||
)
|
||||
|
||||
# We never override an override, because we don't want to interfere
|
||||
# with user-defined overrides that might be in EXTRA_PATH_METADATA.
|
||||
if hasattr(self, "override_save_as") or hasattr(self, "override_url"):
|
||||
if new_save_as != self.save_as or new_url != self.url:
|
||||
_log_reason("its output location was already overridden")
|
||||
return
|
||||
|
||||
# We never change an output path that has already been referenced,
|
||||
# because we don't want to break links that depend on that path.
|
||||
if self._output_location_referenced:
|
||||
if new_save_as != self.save_as or new_url != self.url:
|
||||
_log_reason("another link already referenced its location")
|
||||
return
|
||||
|
||||
self.override_save_as = new_save_as
|
||||
self.override_url = new_url
|
||||
File diff suppressed because it is too large
Load diff
174
pelican/log.py
174
pelican/log.py
|
|
@ -1,174 +0,0 @@
|
|||
import logging
|
||||
from collections import defaultdict
|
||||
|
||||
from rich.console import Console
|
||||
from rich.logging import RichHandler
|
||||
|
||||
__all__ = ["init"]
|
||||
|
||||
console = Console()
|
||||
|
||||
|
||||
class LimitFilter(logging.Filter):
|
||||
"""
|
||||
Remove duplicates records, and limit the number of records in the same
|
||||
group.
|
||||
|
||||
Groups are specified by the message to use when the number of records in
|
||||
the same group hit the limit.
|
||||
E.g.: log.warning(('43 is not the answer', 'More erroneous answers'))
|
||||
"""
|
||||
|
||||
LOGS_DEDUP_MIN_LEVEL = logging.WARNING
|
||||
|
||||
_ignore = set()
|
||||
_raised_messages = set()
|
||||
_threshold = 5
|
||||
_group_count = defaultdict(int)
|
||||
|
||||
def filter(self, record):
|
||||
# don't limit log messages for anything above "warning"
|
||||
if record.levelno > self.LOGS_DEDUP_MIN_LEVEL:
|
||||
return True
|
||||
|
||||
# extract group
|
||||
group = record.__dict__.get("limit_msg", None)
|
||||
group_args = record.__dict__.get("limit_args", ())
|
||||
|
||||
# ignore record if it was already raised
|
||||
message_key = (record.levelno, record.getMessage())
|
||||
if message_key in self._raised_messages:
|
||||
return False
|
||||
else:
|
||||
self._raised_messages.add(message_key)
|
||||
|
||||
# ignore LOG_FILTER records by templates or messages
|
||||
# when "debug" isn't enabled
|
||||
logger_level = logging.getLogger().getEffectiveLevel()
|
||||
if logger_level > logging.DEBUG:
|
||||
template_key = (record.levelno, record.msg)
|
||||
message_key = (record.levelno, record.getMessage())
|
||||
if template_key in self._ignore or message_key in self._ignore:
|
||||
return False
|
||||
|
||||
# check if we went over threshold
|
||||
if group:
|
||||
key = (record.levelno, group)
|
||||
self._group_count[key] += 1
|
||||
if self._group_count[key] == self._threshold:
|
||||
record.msg = group
|
||||
record.args = group_args
|
||||
elif self._group_count[key] > self._threshold:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class LimitLogger(logging.Logger):
|
||||
"""
|
||||
A logger which adds LimitFilter automatically
|
||||
"""
|
||||
|
||||
limit_filter = LimitFilter()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.enable_filter()
|
||||
|
||||
def disable_filter(self):
|
||||
self.removeFilter(LimitLogger.limit_filter)
|
||||
|
||||
def enable_filter(self):
|
||||
self.addFilter(LimitLogger.limit_filter)
|
||||
|
||||
|
||||
class FatalLogger(LimitLogger):
|
||||
warnings_fatal = False
|
||||
errors_fatal = False
|
||||
|
||||
def warning(self, *args, stacklevel=1, **kwargs):
|
||||
"""
|
||||
Displays a logging warning.
|
||||
|
||||
Wrapping it here allows Pelican to filter warnings, and conditionally
|
||||
make warnings fatal.
|
||||
|
||||
Args:
|
||||
stacklevel (int): the stacklevel that would be used to display the
|
||||
calling location, except for this function. Adjusting the
|
||||
stacklevel allows you to see the "true" calling location of the
|
||||
warning, rather than this wrapper location.
|
||||
"""
|
||||
stacklevel += 1
|
||||
super().warning(*args, stacklevel=stacklevel, **kwargs)
|
||||
if FatalLogger.warnings_fatal:
|
||||
raise RuntimeError("Warning encountered")
|
||||
|
||||
def error(self, *args, stacklevel=1, **kwargs):
|
||||
"""
|
||||
Displays a logging error.
|
||||
|
||||
Wrapping it here allows Pelican to filter errors, and conditionally
|
||||
make errors non-fatal.
|
||||
|
||||
Args:
|
||||
stacklevel (int): the stacklevel that would be used to display the
|
||||
calling location, except for this function. Adjusting the
|
||||
stacklevel allows you to see the "true" calling location of the
|
||||
error, rather than this wrapper location.
|
||||
"""
|
||||
stacklevel += 1
|
||||
super().error(*args, stacklevel=stacklevel, **kwargs)
|
||||
if FatalLogger.errors_fatal:
|
||||
raise RuntimeError("Error encountered")
|
||||
|
||||
|
||||
logging.setLoggerClass(FatalLogger)
|
||||
# force root logger to be of our preferred class
|
||||
logging.getLogger().__class__ = FatalLogger
|
||||
|
||||
DEFAULT_LOG_HANDLER = RichHandler(console=console)
|
||||
|
||||
|
||||
def init(
|
||||
level=None,
|
||||
fatal="",
|
||||
handler=DEFAULT_LOG_HANDLER,
|
||||
name=None,
|
||||
logs_dedup_min_level=None,
|
||||
):
|
||||
FatalLogger.warnings_fatal = fatal.startswith("warning")
|
||||
FatalLogger.errors_fatal = bool(fatal)
|
||||
|
||||
LOG_FORMAT = "%(message)s"
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format=LOG_FORMAT,
|
||||
datefmt="[%H:%M:%S]",
|
||||
handlers=[handler] if handler else [],
|
||||
)
|
||||
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if level:
|
||||
logger.setLevel(level)
|
||||
if logs_dedup_min_level:
|
||||
LimitFilter.LOGS_DEDUP_MIN_LEVEL = logs_dedup_min_level
|
||||
|
||||
|
||||
def log_warnings():
|
||||
import warnings
|
||||
|
||||
logging.captureWarnings(True)
|
||||
warnings.simplefilter("default", DeprecationWarning)
|
||||
init(logging.DEBUG, name="py.warnings")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init(level=logging.DEBUG, name=__name__)
|
||||
|
||||
root_logger = logging.getLogger(__name__)
|
||||
root_logger.debug("debug")
|
||||
root_logger.info("info")
|
||||
root_logger.warning("warning")
|
||||
root_logger.error("error")
|
||||
root_logger.critical("critical")
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
import functools
|
||||
import logging
|
||||
import os
|
||||
from collections import namedtuple
|
||||
from math import ceil
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PaginationRule = namedtuple( # noqa: PYI024
|
||||
"PaginationRule",
|
||||
"min_page URL SAVE_AS",
|
||||
)
|
||||
|
||||
|
||||
class Paginator:
|
||||
def __init__(self, name, url, object_list, settings, per_page=None):
|
||||
self.name = name
|
||||
self.url = url
|
||||
self.object_list = object_list
|
||||
self.settings = settings
|
||||
if per_page:
|
||||
self.per_page = per_page
|
||||
self.orphans = settings["DEFAULT_ORPHANS"]
|
||||
else:
|
||||
self.per_page = len(object_list)
|
||||
self.orphans = 0
|
||||
|
||||
self._num_pages = self._count = None
|
||||
|
||||
def page(self, number):
|
||||
"Returns a Page object for the given 1-based page number."
|
||||
bottom = (number - 1) * self.per_page
|
||||
top = bottom + self.per_page
|
||||
if top + self.orphans >= self.count:
|
||||
top = self.count
|
||||
return Page(
|
||||
self.name,
|
||||
self.url,
|
||||
self.object_list[bottom:top],
|
||||
number,
|
||||
self,
|
||||
self.settings,
|
||||
)
|
||||
|
||||
def _get_count(self):
|
||||
"Returns the total number of objects, across all pages."
|
||||
if self._count is None:
|
||||
self._count = len(self.object_list)
|
||||
return self._count
|
||||
|
||||
count = property(_get_count)
|
||||
|
||||
def _get_num_pages(self):
|
||||
"Returns the total number of pages."
|
||||
if self._num_pages is None:
|
||||
hits = max(1, self.count - self.orphans)
|
||||
self._num_pages = int(ceil(hits / (float(self.per_page) or 1)))
|
||||
return self._num_pages
|
||||
|
||||
num_pages = property(_get_num_pages)
|
||||
|
||||
def _get_page_range(self):
|
||||
"""
|
||||
Returns a 1-based range of pages for iterating through within
|
||||
a template for loop.
|
||||
"""
|
||||
return list(range(1, self.num_pages + 1))
|
||||
|
||||
page_range = property(_get_page_range)
|
||||
|
||||
|
||||
class Page:
|
||||
def __init__(self, name, url, object_list, number, paginator, settings):
|
||||
self.full_name = name
|
||||
self.name, self.extension = os.path.splitext(name)
|
||||
dn, fn = os.path.split(name)
|
||||
self.base_name = dn if fn in ("index.htm", "index.html") else self.name
|
||||
self.base_url = url
|
||||
self.object_list = object_list
|
||||
self.number = number
|
||||
self.paginator = paginator
|
||||
self.settings = settings
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Page {self.number} of {self.paginator.num_pages}>"
|
||||
|
||||
def has_next(self):
|
||||
return self.number < self.paginator.num_pages
|
||||
|
||||
def has_previous(self):
|
||||
return self.number > 1
|
||||
|
||||
def has_other_pages(self):
|
||||
return self.has_previous() or self.has_next()
|
||||
|
||||
def next_page_number(self):
|
||||
return self.number + 1
|
||||
|
||||
def previous_page_number(self):
|
||||
return self.number - 1
|
||||
|
||||
def start_index(self):
|
||||
"""
|
||||
Returns the 1-based index of the first object on this page,
|
||||
relative to total objects in the paginator.
|
||||
"""
|
||||
# Special case, return zero if no items.
|
||||
if self.paginator.count == 0:
|
||||
return 0
|
||||
return (self.paginator.per_page * (self.number - 1)) + 1
|
||||
|
||||
def end_index(self):
|
||||
"""
|
||||
Returns the 1-based index of the last object on this page,
|
||||
relative to total objects found (hits).
|
||||
"""
|
||||
# Special case for the last page because there can be orphans.
|
||||
if self.number == self.paginator.num_pages:
|
||||
return self.paginator.count
|
||||
return self.number * self.paginator.per_page
|
||||
|
||||
def _from_settings(self, key):
|
||||
"""Returns URL information as defined in settings. Similar to
|
||||
URLWrapper._from_settings, but specialized to deal with pagination
|
||||
logic."""
|
||||
|
||||
rule = None
|
||||
|
||||
# find the last matching pagination rule
|
||||
for p in self.settings["PAGINATION_PATTERNS"]:
|
||||
if p.min_page == -1:
|
||||
if not self.has_next():
|
||||
rule = p
|
||||
break
|
||||
elif p.min_page <= self.number:
|
||||
rule = p
|
||||
|
||||
if not rule:
|
||||
return ""
|
||||
|
||||
prop_value = getattr(rule, key)
|
||||
|
||||
if not isinstance(prop_value, str):
|
||||
logger.warning("%s is set to %s", key, prop_value)
|
||||
return prop_value
|
||||
|
||||
# URL or SAVE_AS is a string, format it with a controlled context
|
||||
context = {
|
||||
"save_as": self.full_name,
|
||||
"url": self.base_url,
|
||||
"name": self.name,
|
||||
"base_name": self.base_name,
|
||||
"extension": self.extension,
|
||||
"number": self.number,
|
||||
}
|
||||
|
||||
ret = prop_value.format(**context)
|
||||
# Remove a single leading slash, if any. This is done for backwards
|
||||
# compatibility reasons. If a leading slash is needed (for URLs
|
||||
# relative to server root or absolute URLs without the scheme such as
|
||||
# //blog.my.site/), it can be worked around by prefixing the pagination
|
||||
# pattern by an additional slash (which then gets removed, preserving
|
||||
# the other slashes). This also means the following code *can't* be
|
||||
# changed to lstrip() because that would remove all leading slashes and
|
||||
# thus make the workaround impossible. See
|
||||
# test_custom_pagination_pattern() for a verification of this.
|
||||
if ret.startswith("/"):
|
||||
ret = ret[1:]
|
||||
return ret
|
||||
|
||||
url = property(functools.partial(_from_settings, key="URL"))
|
||||
save_as = property(functools.partial(_from_settings, key="SAVE_AS"))
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
import importlib
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def iter_namespace(ns_pkg):
|
||||
# Specifying the second argument (prefix) to iter_modules makes the
|
||||
# returned name an absolute name instead of a relative one. This allows
|
||||
# import_module to work without having to do additional modification to
|
||||
# the name.
|
||||
return pkgutil.iter_modules(ns_pkg.__path__, ns_pkg.__name__ + ".")
|
||||
|
||||
|
||||
def get_namespace_plugins(ns_pkg=None):
|
||||
if ns_pkg is None:
|
||||
import pelican.plugins as ns_pkg
|
||||
|
||||
return {
|
||||
name: importlib.import_module(name)
|
||||
for finder, name, ispkg in iter_namespace(ns_pkg)
|
||||
if ispkg
|
||||
}
|
||||
|
||||
|
||||
def list_plugins(ns_pkg=None):
|
||||
from pelican.log import init as init_logging
|
||||
|
||||
init_logging(logging.INFO)
|
||||
ns_plugins = get_namespace_plugins(ns_pkg)
|
||||
if ns_plugins:
|
||||
logger.info("Plugins found:\n" + "\n".join(ns_plugins))
|
||||
else:
|
||||
logger.info("No plugins are installed")
|
||||
|
||||
|
||||
def plugin_enabled(name, plugin_list=None):
|
||||
if plugin_list is None or not plugin_list:
|
||||
# no plugins are loaded
|
||||
return False
|
||||
|
||||
if name in plugin_list:
|
||||
# search name as is
|
||||
return True
|
||||
|
||||
if f"pelican.plugins.{name}" in plugin_list:
|
||||
# check if short name is a namespace plugin
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def load_legacy_plugin(plugin, plugin_paths):
|
||||
if "." in plugin:
|
||||
# it is in a package, try to resolve package first
|
||||
package, _, _ = plugin.rpartition(".")
|
||||
load_legacy_plugin(package, plugin_paths)
|
||||
|
||||
# Try to find plugin in PLUGIN_PATHS
|
||||
spec = importlib.machinery.PathFinder.find_spec(plugin, plugin_paths)
|
||||
if spec is None:
|
||||
# If failed, try to find it in normal importable locations
|
||||
spec = importlib.util.find_spec(plugin)
|
||||
if spec is None:
|
||||
raise ImportError(f"Cannot import plugin `{plugin}`")
|
||||
else:
|
||||
# Avoid loading the same plugin twice
|
||||
if spec.name in sys.modules:
|
||||
return sys.modules[spec.name]
|
||||
# create module object from spec
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
# place it into sys.modules cache
|
||||
# necessary if module imports itself at some point (e.g. packages)
|
||||
sys.modules[spec.name] = mod
|
||||
try:
|
||||
# try to execute it inside module object
|
||||
spec.loader.exec_module(mod)
|
||||
except Exception: # problem with import
|
||||
try:
|
||||
# remove module from sys.modules since it can't be loaded
|
||||
del sys.modules[spec.name]
|
||||
except KeyError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# if all went well, we have the plugin module
|
||||
return mod
|
||||
|
||||
|
||||
def load_plugins(settings):
|
||||
logger.debug("Finding namespace plugins")
|
||||
namespace_plugins = get_namespace_plugins()
|
||||
if namespace_plugins:
|
||||
logger.debug("Namespace plugins found:\n" + "\n".join(namespace_plugins))
|
||||
plugins = []
|
||||
if settings.get("PLUGINS") is not None:
|
||||
for plugin in settings["PLUGINS"]:
|
||||
if isinstance(plugin, str):
|
||||
logger.debug("Loading plugin `%s`", plugin)
|
||||
# try to find in namespace plugins
|
||||
if plugin in namespace_plugins:
|
||||
plugin = namespace_plugins[plugin]
|
||||
elif f"pelican.plugins.{plugin}" in namespace_plugins:
|
||||
plugin = namespace_plugins[f"pelican.plugins.{plugin}"]
|
||||
# try to import it
|
||||
else:
|
||||
try:
|
||||
plugin = load_legacy_plugin(
|
||||
plugin, settings.get("PLUGIN_PATHS", [])
|
||||
)
|
||||
except ImportError as e:
|
||||
logger.error("Cannot load plugin `%s`\n%s", plugin, e)
|
||||
continue
|
||||
plugins.append(plugin)
|
||||
else:
|
||||
plugins = list(namespace_plugins.values())
|
||||
|
||||
return plugins
|
||||
|
||||
|
||||
def get_plugin_name(plugin):
|
||||
"""
|
||||
Plugins can be passed as module objects, however this breaks caching as
|
||||
module objects cannot be pickled. To work around this, all plugins are
|
||||
stringified post-initialization.
|
||||
"""
|
||||
if inspect.isclass(plugin):
|
||||
return plugin.__qualname__
|
||||
|
||||
if inspect.ismodule(plugin):
|
||||
return plugin.__name__
|
||||
|
||||
return type(plugin).__qualname__
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
from blinker import Signal, signal
|
||||
from ordered_set import OrderedSet
|
||||
|
||||
# Signals will call functions in the order of connection, i.e. plugin order
|
||||
Signal.set_class = OrderedSet
|
||||
|
||||
# Run-level signals:
|
||||
|
||||
initialized = signal("pelican_initialized")
|
||||
get_generators = signal("get_generators")
|
||||
all_generators_finalized = signal("all_generators_finalized")
|
||||
get_writer = signal("get_writer")
|
||||
finalized = signal("pelican_finalized")
|
||||
|
||||
# Reader-level signals
|
||||
|
||||
readers_init = signal("readers_init")
|
||||
|
||||
# Generator-level signals
|
||||
|
||||
generator_init = signal("generator_init")
|
||||
|
||||
article_generator_init = signal("article_generator_init")
|
||||
article_generator_pretaxonomy = signal("article_generator_pretaxonomy")
|
||||
article_generator_finalized = signal("article_generator_finalized")
|
||||
article_generator_write_article = signal("article_generator_write_article")
|
||||
article_writer_finalized = signal("article_writer_finalized")
|
||||
|
||||
page_generator_init = signal("page_generator_init")
|
||||
page_generator_finalized = signal("page_generator_finalized")
|
||||
page_generator_write_page = signal("page_generator_write_page")
|
||||
page_writer_finalized = signal("page_writer_finalized")
|
||||
|
||||
static_generator_init = signal("static_generator_init")
|
||||
static_generator_finalized = signal("static_generator_finalized")
|
||||
|
||||
# Page-level signals
|
||||
|
||||
article_generator_preread = signal("article_generator_preread")
|
||||
article_generator_context = signal("article_generator_context")
|
||||
|
||||
page_generator_preread = signal("page_generator_preread")
|
||||
page_generator_context = signal("page_generator_context")
|
||||
|
||||
static_generator_preread = signal("static_generator_preread")
|
||||
static_generator_context = signal("static_generator_context")
|
||||
|
||||
content_object_init = signal("content_object_init")
|
||||
|
||||
# Writers signals
|
||||
content_written = signal("content_written")
|
||||
feed_generated = signal("feed_generated")
|
||||
feed_written = signal("feed_written")
|
||||
|
|
@ -1,811 +0,0 @@
|
|||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
from html import escape
|
||||
from html.parser import HTMLParser
|
||||
from io import StringIO
|
||||
|
||||
import docutils
|
||||
import docutils.core
|
||||
import docutils.io
|
||||
from docutils.parsers.rst.languages import get_language as get_docutils_lang
|
||||
from docutils.writers.html4css1 import HTMLTranslator, Writer
|
||||
|
||||
from pelican import rstdirectives # NOQA
|
||||
from pelican.cache import FileStampDataCacher
|
||||
from pelican.contents import Author, Category, Page, SkipStub, Tag
|
||||
from pelican.plugins import signals
|
||||
from pelican.utils import file_suffix, get_date, pelican_open, posixize_path
|
||||
|
||||
try:
|
||||
from markdown import Markdown
|
||||
except ImportError:
|
||||
Markdown = False
|
||||
|
||||
# Metadata processors have no way to discard an unwanted value, so we have
|
||||
# them return this value instead to signal that it should be discarded later.
|
||||
# This means that _filter_discardable_metadata() must be called on processed
|
||||
# metadata dicts before use, to remove the items with the special value.
|
||||
_DISCARD = object()
|
||||
|
||||
DUPLICATES_DEFINITIONS_ALLOWED = {
|
||||
"tags": False,
|
||||
"date": False,
|
||||
"modified": False,
|
||||
"status": False,
|
||||
"category": False,
|
||||
"author": False,
|
||||
"save_as": False,
|
||||
"url": False,
|
||||
"authors": False,
|
||||
"slug": False,
|
||||
}
|
||||
|
||||
METADATA_PROCESSORS = {
|
||||
"tags": lambda x, y: ([Tag(tag, y) for tag in ensure_metadata_list(x)] or _DISCARD),
|
||||
"date": lambda x, y: get_date(x.replace("_", " ")),
|
||||
"modified": lambda x, y: get_date(x),
|
||||
"status": lambda x, y: x.strip() or _DISCARD,
|
||||
"category": lambda x, y: _process_if_nonempty(Category, x, y),
|
||||
"author": lambda x, y: _process_if_nonempty(Author, x, y),
|
||||
"authors": lambda x, y: (
|
||||
[Author(author, y) for author in ensure_metadata_list(x)] or _DISCARD
|
||||
),
|
||||
"slug": lambda x, y: x.strip() or _DISCARD,
|
||||
}
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_metadata_list(text):
|
||||
"""Canonicalize the format of a list of authors or tags. This works
|
||||
the same way as Docutils' "authors" field: if it's already a list,
|
||||
those boundaries are preserved; otherwise, it must be a string;
|
||||
if the string contains semicolons, it is split on semicolons;
|
||||
otherwise, it is split on commas. This allows you to write
|
||||
author lists in either "Jane Doe, John Doe" or "Doe, Jane; Doe, John"
|
||||
format.
|
||||
|
||||
Regardless, all list items undergo .strip() before returning, and
|
||||
empty items are discarded.
|
||||
"""
|
||||
if isinstance(text, str):
|
||||
if ";" in text:
|
||||
text = text.split(";")
|
||||
else:
|
||||
text = text.split(",")
|
||||
|
||||
return list(OrderedDict.fromkeys([v for v in (w.strip() for w in text) if v]))
|
||||
|
||||
|
||||
def _process_if_nonempty(processor, name, settings):
|
||||
"""Removes extra whitespace from name and applies a metadata processor.
|
||||
If name is empty or all whitespace, returns _DISCARD instead.
|
||||
"""
|
||||
name = name.strip()
|
||||
return processor(name, settings) if name else _DISCARD
|
||||
|
||||
|
||||
def _filter_discardable_metadata(metadata):
|
||||
"""Return a copy of a dict, minus any items marked as discardable."""
|
||||
return {name: val for name, val in metadata.items() if val is not _DISCARD}
|
||||
|
||||
|
||||
class BaseReader:
|
||||
"""Base class to read files.
|
||||
|
||||
This class is used to process static files, and it can be inherited for
|
||||
other types of file. A Reader class must have the following attributes:
|
||||
|
||||
- enabled: (boolean) tell if the Reader class is enabled. It
|
||||
generally depends on the import of some dependency.
|
||||
- file_extensions: a list of file extensions that the Reader will process.
|
||||
- extensions: a list of extensions to use in the reader (typical use is
|
||||
Markdown).
|
||||
|
||||
"""
|
||||
|
||||
enabled = True
|
||||
file_extensions = ["static"]
|
||||
extensions = None
|
||||
|
||||
def __init__(self, settings):
|
||||
self.settings = settings
|
||||
|
||||
def process_metadata(self, name, value):
|
||||
if name in METADATA_PROCESSORS:
|
||||
return METADATA_PROCESSORS[name](value, self.settings)
|
||||
return value
|
||||
|
||||
def read(self, source_path):
|
||||
"No-op parser"
|
||||
content = None
|
||||
metadata = {}
|
||||
return content, metadata
|
||||
|
||||
def disabled_message(self) -> str:
|
||||
"""Message about why this plugin was disabled."""
|
||||
return ""
|
||||
|
||||
|
||||
class _FieldBodyTranslator(HTMLTranslator):
|
||||
def __init__(self, document):
|
||||
super().__init__(document)
|
||||
self.compact_p = None
|
||||
|
||||
def astext(self):
|
||||
return "".join(self.body)
|
||||
|
||||
def visit_field_body(self, node):
|
||||
pass
|
||||
|
||||
def depart_field_body(self, node):
|
||||
pass
|
||||
|
||||
|
||||
def render_node_to_html(document, node, field_body_translator_class):
|
||||
visitor = field_body_translator_class(document)
|
||||
node.walkabout(visitor)
|
||||
return visitor.astext()
|
||||
|
||||
|
||||
class PelicanHTMLWriter(Writer):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.translator_class = PelicanHTMLTranslator
|
||||
|
||||
|
||||
class PelicanHTMLTranslator(HTMLTranslator):
|
||||
def visit_abbreviation(self, node):
|
||||
attrs = {}
|
||||
if node.hasattr("explanation"):
|
||||
attrs["title"] = node["explanation"]
|
||||
self.body.append(self.starttag(node, "abbr", "", **attrs))
|
||||
|
||||
def depart_abbreviation(self, node):
|
||||
self.body.append("</abbr>")
|
||||
|
||||
def visit_image(self, node):
|
||||
# set an empty alt if alt is not specified
|
||||
# avoids that alt is taken from src
|
||||
node["alt"] = node.get("alt", "")
|
||||
return HTMLTranslator.visit_image(self, node)
|
||||
|
||||
|
||||
class RstReader(BaseReader):
|
||||
"""Reader for reStructuredText files
|
||||
|
||||
By default the output HTML is written using
|
||||
docutils.writers.html4css1.Writer and translated using a subclass of
|
||||
docutils.writers.html4css1.HTMLTranslator. If you want to override it with
|
||||
your own writer/translator (e.g. a HTML5-based one), pass your classes to
|
||||
these two attributes. Look in the source code for details.
|
||||
|
||||
writer_class Used for writing contents
|
||||
field_body_translator_class Used for translating metadata such
|
||||
as article summary
|
||||
|
||||
"""
|
||||
|
||||
enabled = bool(docutils)
|
||||
file_extensions = ["rst"]
|
||||
|
||||
writer_class = PelicanHTMLWriter
|
||||
field_body_translator_class = _FieldBodyTranslator
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
lang_code = self.settings.get("DEFAULT_LANG", "en")
|
||||
if get_docutils_lang(lang_code):
|
||||
self._language_code = lang_code
|
||||
else:
|
||||
logger.warning(
|
||||
"Docutils has no localization for '%s'. Using 'en' instead.",
|
||||
lang_code,
|
||||
)
|
||||
self._language_code = "en"
|
||||
|
||||
def _parse_metadata(self, document, source_path):
|
||||
"""Return the dict containing document metadata"""
|
||||
formatted_fields = self.settings["FORMATTED_FIELDS"]
|
||||
|
||||
output = {}
|
||||
|
||||
if document.first_child_matching_class(docutils.nodes.title) is None:
|
||||
logger.warning(
|
||||
"Document title missing in file %s: "
|
||||
"Ensure exactly one top level section",
|
||||
source_path,
|
||||
)
|
||||
|
||||
try:
|
||||
# docutils 0.18.1+
|
||||
nodes = document.findall(docutils.nodes.docinfo)
|
||||
except AttributeError:
|
||||
# docutils 0.18.0 or before
|
||||
nodes = document.traverse(docutils.nodes.docinfo)
|
||||
|
||||
for docinfo in nodes:
|
||||
for element in docinfo.children:
|
||||
if element.tagname == "field": # custom fields (e.g. summary)
|
||||
name_elem, body_elem = element.children
|
||||
name = name_elem.astext()
|
||||
if name.lower() in formatted_fields:
|
||||
value = render_node_to_html(
|
||||
document, body_elem, self.field_body_translator_class
|
||||
)
|
||||
else:
|
||||
value = body_elem.astext()
|
||||
elif element.tagname == "authors": # author list
|
||||
name = element.tagname
|
||||
value = [element.astext() for element in element.children]
|
||||
else: # standard fields (e.g. address)
|
||||
name = element.tagname
|
||||
value = element.astext()
|
||||
name = name.lower()
|
||||
|
||||
output[name] = self.process_metadata(name, value)
|
||||
return output
|
||||
|
||||
def _get_publisher(self, source_path):
|
||||
extra_params = {
|
||||
"initial_header_level": "2",
|
||||
"syntax_highlight": "short",
|
||||
"input_encoding": "utf-8",
|
||||
"language_code": self._language_code,
|
||||
"halt_level": 2,
|
||||
"traceback": True,
|
||||
"warning_stream": StringIO(),
|
||||
"embed_stylesheet": False,
|
||||
}
|
||||
user_params = self.settings.get("DOCUTILS_SETTINGS")
|
||||
if user_params:
|
||||
extra_params.update(user_params)
|
||||
|
||||
pub = docutils.core.Publisher(
|
||||
writer=self.writer_class(), destination_class=docutils.io.StringOutput
|
||||
)
|
||||
pub.set_components("standalone", "restructuredtext", "html")
|
||||
pub.process_programmatic_settings(None, extra_params, None)
|
||||
pub.set_source(source_path=source_path)
|
||||
pub.publish()
|
||||
return pub
|
||||
|
||||
def read(self, source_path):
|
||||
"""Parses restructured text"""
|
||||
pub = self._get_publisher(source_path)
|
||||
parts = pub.writer.parts
|
||||
content = parts.get("body")
|
||||
|
||||
metadata = self._parse_metadata(pub.document, source_path)
|
||||
metadata.setdefault("title", parts.get("title"))
|
||||
|
||||
return content, metadata
|
||||
|
||||
|
||||
class MarkdownReader(BaseReader):
|
||||
"""Reader for Markdown files"""
|
||||
|
||||
enabled = bool(Markdown)
|
||||
file_extensions = ["md", "markdown", "mkd", "mdown"]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
settings = self.settings["MARKDOWN"]
|
||||
settings.setdefault("extension_configs", {})
|
||||
settings.setdefault("extensions", [])
|
||||
for extension in settings["extension_configs"].keys():
|
||||
if extension not in settings["extensions"]:
|
||||
settings["extensions"].append(extension)
|
||||
if "markdown.extensions.meta" not in settings["extensions"]:
|
||||
settings["extensions"].append("markdown.extensions.meta")
|
||||
self._source_path = None
|
||||
|
||||
def _parse_metadata(self, meta):
|
||||
"""Return the dict containing document metadata"""
|
||||
formatted_fields = self.settings["FORMATTED_FIELDS"]
|
||||
|
||||
# prevent metadata extraction in fields
|
||||
self._md.preprocessors.deregister("meta")
|
||||
|
||||
output = {}
|
||||
for name, value in meta.items():
|
||||
name = name.lower()
|
||||
if name in formatted_fields:
|
||||
# formatted metadata is special case and join all list values
|
||||
formatted_values = "\n".join(value)
|
||||
# reset the markdown instance to clear any state
|
||||
self._md.reset()
|
||||
formatted = self._md.convert(formatted_values)
|
||||
output[name] = self.process_metadata(name, formatted)
|
||||
elif not DUPLICATES_DEFINITIONS_ALLOWED.get(name, True):
|
||||
if len(value) > 1:
|
||||
logger.warning(
|
||||
"Duplicate definition of `%s` for %s. Using first one.",
|
||||
name,
|
||||
self._source_path,
|
||||
)
|
||||
output[name] = self.process_metadata(name, value[0])
|
||||
elif len(value) > 1:
|
||||
# handle list metadata as list of string
|
||||
output[name] = self.process_metadata(name, value)
|
||||
else:
|
||||
# otherwise, handle metadata as single string
|
||||
output[name] = self.process_metadata(name, value[0])
|
||||
return output
|
||||
|
||||
def read(self, source_path):
|
||||
"""Parse content and metadata of markdown files"""
|
||||
|
||||
self._source_path = source_path
|
||||
self._md = Markdown(**self.settings["MARKDOWN"])
|
||||
with pelican_open(source_path) as text:
|
||||
content = self._md.convert(text)
|
||||
|
||||
if hasattr(self._md, "Meta"):
|
||||
metadata = self._parse_metadata(self._md.Meta)
|
||||
else:
|
||||
metadata = {}
|
||||
return content, metadata
|
||||
|
||||
def disabled_message(self) -> str:
|
||||
return (
|
||||
"Could not import 'markdown.Markdown'. "
|
||||
"Have you installed the 'markdown' package?"
|
||||
)
|
||||
|
||||
|
||||
class HTMLReader(BaseReader):
|
||||
"""Parses HTML files as input, looking for meta, title, and body tags"""
|
||||
|
||||
file_extensions = ["htm", "html"]
|
||||
enabled = True
|
||||
|
||||
class _HTMLParser(HTMLParser):
|
||||
def __init__(self, settings, filename):
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.body = ""
|
||||
self.metadata = {}
|
||||
self.settings = settings
|
||||
|
||||
self._data_buffer = ""
|
||||
|
||||
self._filename = filename
|
||||
|
||||
self._in_top_level = True
|
||||
self._in_head = False
|
||||
self._in_title = False
|
||||
self._in_body = False
|
||||
self._in_tags = False
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == "head" and self._in_top_level:
|
||||
self._in_top_level = False
|
||||
self._in_head = True
|
||||
elif tag == "title" and self._in_head:
|
||||
self._in_title = True
|
||||
self._data_buffer = ""
|
||||
elif tag == "body" and self._in_top_level:
|
||||
self._in_top_level = False
|
||||
self._in_body = True
|
||||
self._data_buffer = ""
|
||||
elif tag == "meta" and self._in_head:
|
||||
self._handle_meta_tag(attrs)
|
||||
|
||||
elif self._in_body:
|
||||
self._data_buffer += self.build_tag(tag, attrs, False)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == "head":
|
||||
if self._in_head:
|
||||
self._in_head = False
|
||||
self._in_top_level = True
|
||||
elif self._in_head and tag == "title":
|
||||
self._in_title = False
|
||||
self.metadata["title"] = self._data_buffer
|
||||
elif tag == "body":
|
||||
self.body = self._data_buffer
|
||||
self._in_body = False
|
||||
self._in_top_level = True
|
||||
elif self._in_body:
|
||||
self._data_buffer += f"</{escape(tag)}>"
|
||||
|
||||
def handle_startendtag(self, tag, attrs):
|
||||
if tag == "meta" and self._in_head:
|
||||
self._handle_meta_tag(attrs)
|
||||
if self._in_body:
|
||||
self._data_buffer += self.build_tag(tag, attrs, True)
|
||||
|
||||
def handle_comment(self, data):
|
||||
self._data_buffer += f"<!--{data}-->"
|
||||
|
||||
def handle_data(self, data):
|
||||
self._data_buffer += data
|
||||
|
||||
def handle_entityref(self, data):
|
||||
self._data_buffer += f"&{data};"
|
||||
|
||||
def handle_charref(self, data):
|
||||
self._data_buffer += f"&#{data};"
|
||||
|
||||
def build_tag(self, tag, attrs, close_tag):
|
||||
result = f"<{escape(tag)}"
|
||||
for k, v in attrs:
|
||||
result += " " + escape(k)
|
||||
if v is not None:
|
||||
# If the attribute value contains a double quote, surround
|
||||
# with single quotes, otherwise use double quotes.
|
||||
if '"' in v:
|
||||
result += f"='{escape(v, quote=False)}'"
|
||||
else:
|
||||
result += f'="{escape(v, quote=False)}"'
|
||||
if close_tag:
|
||||
return result + " />"
|
||||
return result + ">"
|
||||
|
||||
def _handle_meta_tag(self, attrs):
|
||||
name = self._attr_value(attrs, "name")
|
||||
if name is None:
|
||||
attr_list = [f'{k}="{v}"' for k, v in attrs]
|
||||
attr_serialized = ", ".join(attr_list)
|
||||
logger.warning(
|
||||
"Meta tag in file %s does not have a 'name' "
|
||||
"attribute, skipping. Attributes: %s",
|
||||
self._filename,
|
||||
attr_serialized,
|
||||
)
|
||||
return
|
||||
name = name.lower()
|
||||
contents = self._attr_value(attrs, "content", "")
|
||||
if not contents:
|
||||
contents = self._attr_value(attrs, "contents", "")
|
||||
if contents:
|
||||
logger.warning(
|
||||
"Meta tag attribute 'contents' used in file %s, should"
|
||||
" be changed to 'content'",
|
||||
self._filename,
|
||||
extra={
|
||||
"limit_msg": "Other files have meta tag "
|
||||
"attribute 'contents' that should "
|
||||
"be changed to 'content'"
|
||||
},
|
||||
)
|
||||
|
||||
if name == "keywords":
|
||||
name = "tags"
|
||||
|
||||
if name in self.metadata:
|
||||
# if this metadata already exists (i.e. a previous tag with the
|
||||
# same name has already been specified then either convert to
|
||||
# list or append to list
|
||||
if isinstance(self.metadata[name], list):
|
||||
self.metadata[name].append(contents)
|
||||
else:
|
||||
self.metadata[name] = [self.metadata[name], contents]
|
||||
else:
|
||||
self.metadata[name] = contents
|
||||
|
||||
@classmethod
|
||||
def _attr_value(cls, attrs, name, default=None):
|
||||
return next((x[1] for x in attrs if x[0] == name), default)
|
||||
|
||||
def read(self, filename):
|
||||
"""Parse content and metadata of HTML files"""
|
||||
with pelican_open(filename) as content:
|
||||
parser = self._HTMLParser(self.settings, filename)
|
||||
parser.feed(content)
|
||||
parser.close()
|
||||
|
||||
metadata = {}
|
||||
for k in parser.metadata:
|
||||
metadata[k] = self.process_metadata(k, parser.metadata[k])
|
||||
return parser.body, metadata
|
||||
|
||||
|
||||
class Readers(FileStampDataCacher):
|
||||
"""Interface for all readers.
|
||||
|
||||
This class contains a mapping of file extensions / Reader classes, to know
|
||||
which Reader class must be used to read a file (based on its extension).
|
||||
This is customizable both with the 'READERS' setting, and with the
|
||||
'readers_init' signall for plugins.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, settings=None, cache_name=""):
|
||||
self.settings = settings or {}
|
||||
self.readers = {}
|
||||
self.disabled_readers = {}
|
||||
# extension => reader for readers that are enabled
|
||||
self.reader_classes = {}
|
||||
# extension => reader for readers that are not enabled
|
||||
disabled_reader_classes = {}
|
||||
|
||||
for cls in [BaseReader] + BaseReader.__subclasses__():
|
||||
if not cls.enabled:
|
||||
logger.debug(
|
||||
"Missing dependencies for %s", ", ".join(cls.file_extensions)
|
||||
)
|
||||
|
||||
for ext in cls.file_extensions:
|
||||
if cls.enabled:
|
||||
self.reader_classes[ext] = cls
|
||||
else:
|
||||
disabled_reader_classes[ext] = cls
|
||||
|
||||
if self.settings["READERS"]:
|
||||
self.reader_classes.update(self.settings["READERS"])
|
||||
|
||||
signals.readers_init.send(self)
|
||||
|
||||
for fmt, reader_class in self.reader_classes.items():
|
||||
if not reader_class:
|
||||
continue
|
||||
|
||||
self.readers[fmt] = reader_class(self.settings)
|
||||
|
||||
for fmt, reader_class in disabled_reader_classes.items():
|
||||
self.disabled_readers[fmt] = reader_class(self.settings)
|
||||
|
||||
# set up caching
|
||||
cache_this_level = (
|
||||
cache_name != "" and self.settings["CONTENT_CACHING_LAYER"] == "reader"
|
||||
)
|
||||
caching_policy = cache_this_level and self.settings["CACHE_CONTENT"]
|
||||
load_policy = cache_this_level and self.settings["LOAD_CONTENT_CACHE"]
|
||||
super().__init__(settings, cache_name, caching_policy, load_policy)
|
||||
|
||||
@property
|
||||
def extensions(self):
|
||||
"""File extensions that will be processed by a reader."""
|
||||
return self.readers.keys()
|
||||
|
||||
@property
|
||||
def disabled_extensions(self):
|
||||
return self.disabled_readers.keys()
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
base_path,
|
||||
path,
|
||||
content_class=Page,
|
||||
fmt=None,
|
||||
context=None,
|
||||
preread_signal=None,
|
||||
preread_sender=None,
|
||||
context_signal=None,
|
||||
context_sender=None,
|
||||
):
|
||||
"""Return a content object parsed with the given format."""
|
||||
|
||||
path = os.path.abspath(os.path.join(base_path, path))
|
||||
source_path = posixize_path(os.path.relpath(path, base_path))
|
||||
logger.debug("Read file %s -> %s", source_path, content_class.__name__)
|
||||
|
||||
if not fmt:
|
||||
fmt = file_suffix(path)
|
||||
|
||||
if fmt not in self.readers:
|
||||
raise TypeError("Pelican does not know how to parse %s", path)
|
||||
|
||||
if preread_signal:
|
||||
logger.debug("Signal %s.send(%s)", preread_signal.name, preread_sender)
|
||||
preread_signal.send(preread_sender)
|
||||
|
||||
reader = self.readers[fmt]
|
||||
|
||||
metadata = _filter_discardable_metadata(
|
||||
default_metadata(settings=self.settings, process=reader.process_metadata)
|
||||
)
|
||||
metadata.update(
|
||||
path_metadata(
|
||||
full_path=path, source_path=source_path, settings=self.settings
|
||||
)
|
||||
)
|
||||
metadata.update(
|
||||
_filter_discardable_metadata(
|
||||
parse_path_metadata(
|
||||
source_path=source_path,
|
||||
settings=self.settings,
|
||||
process=reader.process_metadata,
|
||||
)
|
||||
)
|
||||
)
|
||||
reader_name = reader.__class__.__name__
|
||||
metadata["reader"] = reader_name.replace("Reader", "").lower()
|
||||
|
||||
content, reader_metadata = self.get_cached_data(path, (None, None))
|
||||
if content is None:
|
||||
content, reader_metadata = reader.read(path)
|
||||
reader_metadata = _filter_discardable_metadata(reader_metadata)
|
||||
self.cache_data(path, (content, reader_metadata))
|
||||
metadata.update(reader_metadata)
|
||||
|
||||
if content:
|
||||
# find images with empty alt
|
||||
find_empty_alt(content, path)
|
||||
|
||||
# eventually filter the content with typogrify if asked so
|
||||
if self.settings["TYPOGRIFY"]:
|
||||
import smartypants
|
||||
from typogrify.filters import typogrify
|
||||
|
||||
typogrify_dashes = self.settings["TYPOGRIFY_DASHES"]
|
||||
if typogrify_dashes == "oldschool":
|
||||
smartypants.Attr.default = smartypants.Attr.set2
|
||||
elif typogrify_dashes == "oldschool_inverted":
|
||||
smartypants.Attr.default = smartypants.Attr.set3
|
||||
else:
|
||||
smartypants.Attr.default = smartypants.Attr.set1
|
||||
|
||||
# Tell `smartypants` to also replace " HTML entities with
|
||||
# smart quotes. This is necessary because Docutils has already
|
||||
# replaced double quotes with said entities by the time we run
|
||||
# this filter.
|
||||
smartypants.Attr.default |= smartypants.Attr.w
|
||||
|
||||
def typogrify_wrapper(text):
|
||||
"""Ensures ignore_tags feature is backward compatible"""
|
||||
try:
|
||||
return typogrify(text, self.settings["TYPOGRIFY_IGNORE_TAGS"])
|
||||
except TypeError:
|
||||
return typogrify(text)
|
||||
|
||||
if content:
|
||||
content = typogrify_wrapper(content)
|
||||
|
||||
if "title" in metadata:
|
||||
metadata["title"] = typogrify_wrapper(metadata["title"])
|
||||
|
||||
if "summary" in metadata:
|
||||
metadata["summary"] = typogrify_wrapper(metadata["summary"])
|
||||
|
||||
if context_signal:
|
||||
logger.debug(
|
||||
"Signal %s.send(%s, <metadata>)", context_signal.name, context_sender
|
||||
)
|
||||
context_signal.send(context_sender, metadata=metadata)
|
||||
|
||||
if metadata.get("status") == "skip":
|
||||
content_class = SkipStub
|
||||
|
||||
return content_class(
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
settings=self.settings,
|
||||
source_path=path,
|
||||
context=context,
|
||||
)
|
||||
|
||||
def check_file(self, source_path: str) -> None:
|
||||
"""Log a warning if a file is processed by a disabled reader."""
|
||||
reader = self.disabled_readers.get(file_suffix(source_path), None)
|
||||
if reader:
|
||||
logger.warning(f"{source_path}: {reader.disabled_message()}")
|
||||
|
||||
|
||||
def find_empty_alt(content, path):
|
||||
"""Find images with empty alt
|
||||
|
||||
Create warnings for all images with empty alt (up to a certain number),
|
||||
as they are really likely to be accessibility flaws.
|
||||
|
||||
"""
|
||||
imgs = re.compile(
|
||||
r"""
|
||||
(?:
|
||||
# src before alt
|
||||
<img
|
||||
[^\>]*
|
||||
src=(['"])(.*?)\1
|
||||
[^\>]*
|
||||
alt=(['"])\3
|
||||
)|(?:
|
||||
# alt before src
|
||||
<img
|
||||
[^\>]*
|
||||
alt=(['"])\4
|
||||
[^\>]*
|
||||
src=(['"])(.*?)\5
|
||||
)
|
||||
""",
|
||||
re.X,
|
||||
)
|
||||
for match in re.findall(imgs, content):
|
||||
logger.warning(
|
||||
"Empty alt attribute for image %s in %s",
|
||||
os.path.basename(match[1] + match[5]),
|
||||
path,
|
||||
extra={"limit_msg": "Other images have empty alt attributes"},
|
||||
)
|
||||
|
||||
|
||||
def default_metadata(settings=None, process=None):
|
||||
metadata = {}
|
||||
if settings:
|
||||
for name, value in dict(settings.get("DEFAULT_METADATA", {})).items():
|
||||
if process:
|
||||
value = process(name, value)
|
||||
metadata[name] = value
|
||||
if "DEFAULT_CATEGORY" in settings:
|
||||
value = settings["DEFAULT_CATEGORY"]
|
||||
if process:
|
||||
value = process("category", value)
|
||||
metadata["category"] = value
|
||||
if settings.get("DEFAULT_DATE", None) and settings["DEFAULT_DATE"] != "fs":
|
||||
if isinstance(settings["DEFAULT_DATE"], str):
|
||||
metadata["date"] = get_date(settings["DEFAULT_DATE"])
|
||||
else:
|
||||
metadata["date"] = datetime.datetime(*settings["DEFAULT_DATE"])
|
||||
return metadata
|
||||
|
||||
|
||||
def path_metadata(full_path, source_path, settings=None):
|
||||
metadata = {}
|
||||
if settings:
|
||||
if settings.get("DEFAULT_DATE", None) == "fs":
|
||||
metadata["date"] = datetime.datetime.fromtimestamp(
|
||||
os.stat(full_path).st_mtime
|
||||
)
|
||||
metadata["modified"] = metadata["date"]
|
||||
|
||||
# Apply EXTRA_PATH_METADATA for the source path and the paths of any
|
||||
# parent directories. Sorting EPM first ensures that the most specific
|
||||
# path wins conflicts.
|
||||
|
||||
epm = settings.get("EXTRA_PATH_METADATA", {})
|
||||
for path, meta in sorted(epm.items()):
|
||||
# Enforce a trailing slash when checking for parent directories.
|
||||
# This prevents false positives when one file or directory's name
|
||||
# is a prefix of another's.
|
||||
dirpath = posixize_path(os.path.join(path, ""))
|
||||
if source_path == path or source_path.startswith(dirpath):
|
||||
metadata.update(meta)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def parse_path_metadata(source_path, settings=None, process=None):
|
||||
r"""Extract a metadata dictionary from a file's path
|
||||
|
||||
>>> import pprint
|
||||
>>> settings = {
|
||||
... 'FILENAME_METADATA': r'(?P<slug>[^.]*).*',
|
||||
... 'PATH_METADATA':
|
||||
... r'(?P<category>[^/]*)/(?P<date>\d{4}-\d{2}-\d{2})/.*',
|
||||
... }
|
||||
>>> reader = BaseReader(settings=settings)
|
||||
>>> metadata = parse_path_metadata(
|
||||
... source_path='my-cat/2013-01-01/my-slug.html',
|
||||
... settings=settings,
|
||||
... process=reader.process_metadata)
|
||||
>>> pprint.pprint(metadata) # doctest: +ELLIPSIS
|
||||
{'category': <pelican.urlwrappers.Category object at ...>,
|
||||
'date': datetime.datetime(2013, 1, 1, 0, 0),
|
||||
'slug': 'my-slug'}
|
||||
"""
|
||||
metadata = {}
|
||||
dirname, basename = os.path.split(source_path)
|
||||
base, ext = os.path.splitext(basename)
|
||||
subdir = os.path.basename(dirname)
|
||||
if settings:
|
||||
checks = []
|
||||
for key, data in [("FILENAME_METADATA", base), ("PATH_METADATA", source_path)]:
|
||||
checks.append((settings.get(key, None), data))
|
||||
if settings.get("USE_FOLDER_AS_CATEGORY", None):
|
||||
checks.append(("(?P<category>.*)", subdir))
|
||||
for regexp, data in checks:
|
||||
if regexp and data:
|
||||
match = re.match(regexp, data)
|
||||
if match:
|
||||
# .items() for py3k compat.
|
||||
for k, v in match.groupdict().items():
|
||||
k = k.lower() # metadata must be lowercase
|
||||
if v is not None and k not in metadata:
|
||||
if process:
|
||||
v = process(k, v)
|
||||
metadata[k] = v
|
||||
return metadata
|
||||
|
|
@ -1,91 +0,0 @@
|
|||
import re
|
||||
|
||||
from docutils import nodes, utils
|
||||
from docutils.parsers.rst import Directive, directives, roles
|
||||
from pygments import highlight
|
||||
from pygments.formatters import HtmlFormatter
|
||||
from pygments.lexers import TextLexer, get_lexer_by_name
|
||||
|
||||
import pelican.settings as pys
|
||||
|
||||
|
||||
class Pygments(Directive):
|
||||
"""Source code syntax highlighting."""
|
||||
|
||||
required_arguments = 1
|
||||
optional_arguments = 0
|
||||
final_argument_whitespace = True
|
||||
option_spec = {
|
||||
"anchorlinenos": directives.flag,
|
||||
"classprefix": directives.unchanged,
|
||||
"hl_lines": directives.unchanged,
|
||||
"lineanchors": directives.unchanged,
|
||||
"linenos": directives.unchanged,
|
||||
"linenospecial": directives.nonnegative_int,
|
||||
"linenostart": directives.nonnegative_int,
|
||||
"linenostep": directives.nonnegative_int,
|
||||
"lineseparator": directives.unchanged,
|
||||
"linespans": directives.unchanged,
|
||||
"nobackground": directives.flag,
|
||||
"nowrap": directives.flag,
|
||||
"tagsfile": directives.unchanged,
|
||||
"tagurlformat": directives.unchanged,
|
||||
}
|
||||
has_content = True
|
||||
|
||||
def run(self):
|
||||
self.assert_has_content()
|
||||
try:
|
||||
lexer = get_lexer_by_name(self.arguments[0])
|
||||
except ValueError:
|
||||
# no lexer found - use the text one instead of an exception
|
||||
lexer = TextLexer()
|
||||
|
||||
# Fetch the defaults
|
||||
if pys.PYGMENTS_RST_OPTIONS is not None:
|
||||
for k, v in pys.PYGMENTS_RST_OPTIONS.items():
|
||||
# Locally set options overrides the defaults
|
||||
if k not in self.options:
|
||||
self.options[k] = v
|
||||
|
||||
if "linenos" in self.options and self.options["linenos"] not in (
|
||||
"table",
|
||||
"inline",
|
||||
):
|
||||
if self.options["linenos"] == "none":
|
||||
self.options.pop("linenos")
|
||||
else:
|
||||
self.options["linenos"] = "table"
|
||||
|
||||
for flag in ("nowrap", "nobackground", "anchorlinenos"):
|
||||
if flag in self.options:
|
||||
self.options[flag] = True
|
||||
|
||||
# noclasses should already default to False, but just in case...
|
||||
formatter = HtmlFormatter(noclasses=False, **self.options)
|
||||
parsed = highlight("\n".join(self.content), lexer, formatter)
|
||||
return [nodes.raw("", parsed, format="html")]
|
||||
|
||||
|
||||
directives.register_directive("code-block", Pygments)
|
||||
directives.register_directive("sourcecode", Pygments)
|
||||
|
||||
|
||||
_abbr_re = re.compile(r"\((.*)\)$", re.DOTALL)
|
||||
|
||||
|
||||
class abbreviation(nodes.Inline, nodes.TextElement):
|
||||
pass
|
||||
|
||||
|
||||
def abbr_role(typ, rawtext, text, lineno, inliner, options=None, content=None):
|
||||
text = utils.unescape(text)
|
||||
m = _abbr_re.search(text)
|
||||
if m is None:
|
||||
return [abbreviation(text, text)], []
|
||||
abbr = text[: m.start()].strip()
|
||||
expl = m.group(1)
|
||||
return [abbreviation(abbr, abbr, explanation=expl)], []
|
||||
|
||||
|
||||
roles.register_local_role("abbr", abbr_role)
|
||||
|
|
@ -1,166 +0,0 @@
|
|||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import posixpath
|
||||
import ssl
|
||||
import sys
|
||||
import urllib
|
||||
from http import server
|
||||
|
||||
try:
|
||||
from magic import from_file as magic_from_file
|
||||
except ImportError:
|
||||
magic_from_file = None
|
||||
|
||||
from pelican.log import console # noqa: F401
|
||||
from pelican.log import init as init_logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Pelican Development Server",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument(
|
||||
"port", default=8000, type=int, nargs="?", help="Port to Listen On"
|
||||
)
|
||||
parser.add_argument("server", default="", nargs="?", help="Interface to Listen On")
|
||||
parser.add_argument("--ssl", action="store_true", help="Activate SSL listener")
|
||||
parser.add_argument(
|
||||
"--cert",
|
||||
default="./cert.pem",
|
||||
nargs="?",
|
||||
help="Path to certificate file. Relative to current directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
default="./key.pem",
|
||||
nargs="?",
|
||||
help="Path to certificate key file. Relative to current directory",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
default=".",
|
||||
help="Path to pelican source directory to serve. Relative to current directory",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
class ComplexHTTPRequestHandler(server.SimpleHTTPRequestHandler):
|
||||
SUFFIXES = [".html", "/index.html", "/", ""]
|
||||
|
||||
extensions_map = {
|
||||
**server.SimpleHTTPRequestHandler.extensions_map,
|
||||
# web fonts
|
||||
".oft": "font/oft",
|
||||
".sfnt": "font/sfnt",
|
||||
".ttf": "font/ttf",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
}
|
||||
|
||||
def translate_path(self, path):
|
||||
# abandon query parameters
|
||||
path = path.split("?", 1)[0]
|
||||
path = path.split("#", 1)[0]
|
||||
# Don't forget explicit trailing slash when normalizing. Issue17324
|
||||
trailing_slash = path.rstrip().endswith("/")
|
||||
path = urllib.parse.unquote(path)
|
||||
path = posixpath.normpath(path)
|
||||
words = path.split("/")
|
||||
words = filter(None, words)
|
||||
path = self.base_path
|
||||
for word in words:
|
||||
if os.path.dirname(word) or word in (os.curdir, os.pardir):
|
||||
# Ignore components that are not a simple file/directory name
|
||||
continue
|
||||
path = os.path.join(path, word)
|
||||
if trailing_slash:
|
||||
path += "/"
|
||||
return path
|
||||
|
||||
def do_GET(self):
|
||||
# cut off a query string
|
||||
original_path = self.path.split("?", 1)[0]
|
||||
# try to find file
|
||||
self.path = self.get_path_that_exists(original_path)
|
||||
|
||||
if not self.path:
|
||||
return
|
||||
|
||||
server.SimpleHTTPRequestHandler.do_GET(self)
|
||||
|
||||
def get_path_that_exists(self, original_path):
|
||||
# Try to strip trailing slash
|
||||
trailing_slash = original_path.endswith("/")
|
||||
original_path = original_path.rstrip("/")
|
||||
# Try to detect file by applying various suffixes
|
||||
tries = []
|
||||
for suffix in self.SUFFIXES:
|
||||
if not trailing_slash and suffix == "/":
|
||||
# if original request does not have trailing slash, skip the '/' suffix
|
||||
# so that base class can redirect if needed
|
||||
continue
|
||||
path = original_path + suffix
|
||||
if os.path.exists(self.translate_path(path)):
|
||||
return path
|
||||
tries.append(path)
|
||||
logger.warning(
|
||||
"Unable to find `%s` or variations:\n%s", original_path, "\n".join(tries)
|
||||
)
|
||||
return None
|
||||
|
||||
def guess_type(self, path):
|
||||
"""Guess at the mime type for the specified file."""
|
||||
mimetype = server.SimpleHTTPRequestHandler.guess_type(self, path)
|
||||
|
||||
# If the default guess is too generic, try the python-magic library
|
||||
if mimetype == "application/octet-stream" and magic_from_file:
|
||||
mimetype = magic_from_file(path, mime=True)
|
||||
|
||||
return mimetype
|
||||
|
||||
def log_message(self, format, *args):
|
||||
logger.info(format, *args)
|
||||
|
||||
|
||||
class RootedHTTPServer(server.HTTPServer):
|
||||
def __init__(self, base_path, *args, **kwargs):
|
||||
server.HTTPServer.__init__(self, *args, **kwargs)
|
||||
self.RequestHandlerClass.base_path = base_path
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_logging(level=logging.INFO)
|
||||
logger.warning(
|
||||
"'python -m pelican.server' is deprecated.\nThe "
|
||||
"Pelican development server should be run via "
|
||||
"'pelican --listen' or 'pelican -l'.\nThis can be combined "
|
||||
"with regeneration as 'pelican -lr'.\nRerun 'pelican-"
|
||||
"quickstart' to get new Makefile and tasks.py files."
|
||||
)
|
||||
args = parse_arguments()
|
||||
RootedHTTPServer.allow_reuse_address = True
|
||||
try:
|
||||
httpd = RootedHTTPServer(
|
||||
args.path, (args.server, args.port), ComplexHTTPRequestHandler
|
||||
)
|
||||
if args.ssl:
|
||||
httpd.socket = ssl.wrap_socket(
|
||||
httpd.socket, keyfile=args.key, certfile=args.cert, server_side=True
|
||||
)
|
||||
except ssl.SSLError as e:
|
||||
logger.error(
|
||||
"Couldn't open certificate file %s or key file %s", args.cert, args.key
|
||||
)
|
||||
logger.error("Could not listen on port %s, server %s.", args.port, args.server)
|
||||
sys.exit(getattr(e, "exitcode", 1))
|
||||
|
||||
logger.info("Serving at port %s, server %s.", args.port, args.server)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down server.")
|
||||
httpd.socket.close()
|
||||
|
|
@ -1,742 +0,0 @@
|
|||
import copy
|
||||
import importlib.util
|
||||
import inspect
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from os.path import isabs
|
||||
from pathlib import Path
|
||||
from types import ModuleType
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from pelican.log import LimitFilter
|
||||
|
||||
|
||||
def load_source(name: str, path: str) -> ModuleType:
|
||||
spec = importlib.util.spec_from_file_location(name, path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Settings = Dict[str, Any]
|
||||
|
||||
DEFAULT_THEME = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "themes", "notmyidea"
|
||||
)
|
||||
DEFAULT_CONFIG = {
|
||||
"PATH": os.curdir,
|
||||
"ARTICLE_PATHS": [""],
|
||||
"ARTICLE_EXCLUDES": [],
|
||||
"PAGE_PATHS": ["pages"],
|
||||
"PAGE_EXCLUDES": [],
|
||||
"THEME": DEFAULT_THEME,
|
||||
"OUTPUT_PATH": "output",
|
||||
"READERS": {},
|
||||
"STATIC_PATHS": ["images"],
|
||||
"STATIC_EXCLUDES": [],
|
||||
"STATIC_EXCLUDE_SOURCES": True,
|
||||
"THEME_STATIC_DIR": "theme",
|
||||
"THEME_STATIC_PATHS": [
|
||||
"static",
|
||||
],
|
||||
"FEED_ALL_ATOM": "feeds/all.atom.xml",
|
||||
"CATEGORY_FEED_ATOM": "feeds/{slug}.atom.xml",
|
||||
"AUTHOR_FEED_ATOM": "feeds/{slug}.atom.xml",
|
||||
"AUTHOR_FEED_RSS": "feeds/{slug}.rss.xml",
|
||||
"TRANSLATION_FEED_ATOM": "feeds/all-{lang}.atom.xml",
|
||||
"FEED_MAX_ITEMS": 100,
|
||||
"RSS_FEED_SUMMARY_ONLY": True,
|
||||
"FEED_APPEND_REF": False,
|
||||
"SITEURL": "",
|
||||
"SITENAME": "A Pelican Blog",
|
||||
"DISPLAY_PAGES_ON_MENU": True,
|
||||
"DISPLAY_CATEGORIES_ON_MENU": True,
|
||||
"DOCUTILS_SETTINGS": {},
|
||||
"OUTPUT_SOURCES": False,
|
||||
"OUTPUT_SOURCES_EXTENSION": ".text",
|
||||
"USE_FOLDER_AS_CATEGORY": True,
|
||||
"DEFAULT_CATEGORY": "misc",
|
||||
"WITH_FUTURE_DATES": True,
|
||||
"CSS_FILE": "main.css",
|
||||
"NEWEST_FIRST_ARCHIVES": True,
|
||||
"REVERSE_CATEGORY_ORDER": False,
|
||||
"DELETE_OUTPUT_DIRECTORY": False,
|
||||
"OUTPUT_RETENTION": [],
|
||||
"INDEX_SAVE_AS": "index.html",
|
||||
"ARTICLE_URL": "{slug}.html",
|
||||
"ARTICLE_SAVE_AS": "{slug}.html",
|
||||
"ARTICLE_ORDER_BY": "reversed-date",
|
||||
"ARTICLE_LANG_URL": "{slug}-{lang}.html",
|
||||
"ARTICLE_LANG_SAVE_AS": "{slug}-{lang}.html",
|
||||
"DRAFT_URL": "drafts/{slug}.html",
|
||||
"DRAFT_SAVE_AS": "drafts/{slug}.html",
|
||||
"DRAFT_LANG_URL": "drafts/{slug}-{lang}.html",
|
||||
"DRAFT_LANG_SAVE_AS": "drafts/{slug}-{lang}.html",
|
||||
"PAGE_URL": "pages/{slug}.html",
|
||||
"PAGE_SAVE_AS": "pages/{slug}.html",
|
||||
"PAGE_ORDER_BY": "basename",
|
||||
"PAGE_LANG_URL": "pages/{slug}-{lang}.html",
|
||||
"PAGE_LANG_SAVE_AS": "pages/{slug}-{lang}.html",
|
||||
"DRAFT_PAGE_URL": "drafts/pages/{slug}.html",
|
||||
"DRAFT_PAGE_SAVE_AS": "drafts/pages/{slug}.html",
|
||||
"DRAFT_PAGE_LANG_URL": "drafts/pages/{slug}-{lang}.html",
|
||||
"DRAFT_PAGE_LANG_SAVE_AS": "drafts/pages/{slug}-{lang}.html",
|
||||
"STATIC_URL": "{path}",
|
||||
"STATIC_SAVE_AS": "{path}",
|
||||
"STATIC_CREATE_LINKS": False,
|
||||
"STATIC_CHECK_IF_MODIFIED": False,
|
||||
"CATEGORY_URL": "category/{slug}.html",
|
||||
"CATEGORY_SAVE_AS": "category/{slug}.html",
|
||||
"TAG_URL": "tag/{slug}.html",
|
||||
"TAG_SAVE_AS": "tag/{slug}.html",
|
||||
"AUTHOR_URL": "author/{slug}.html",
|
||||
"AUTHOR_SAVE_AS": "author/{slug}.html",
|
||||
"PAGINATION_PATTERNS": [
|
||||
(1, "{name}{extension}", "{name}{extension}"),
|
||||
(2, "{name}{number}{extension}", "{name}{number}{extension}"),
|
||||
],
|
||||
"YEAR_ARCHIVE_URL": "",
|
||||
"YEAR_ARCHIVE_SAVE_AS": "",
|
||||
"MONTH_ARCHIVE_URL": "",
|
||||
"MONTH_ARCHIVE_SAVE_AS": "",
|
||||
"DAY_ARCHIVE_URL": "",
|
||||
"DAY_ARCHIVE_SAVE_AS": "",
|
||||
"RELATIVE_URLS": False,
|
||||
"DEFAULT_LANG": "en",
|
||||
"ARTICLE_TRANSLATION_ID": "slug",
|
||||
"PAGE_TRANSLATION_ID": "slug",
|
||||
"DIRECT_TEMPLATES": ["index", "tags", "categories", "authors", "archives"],
|
||||
"THEME_TEMPLATES_OVERRIDES": [],
|
||||
"PAGINATED_TEMPLATES": {
|
||||
"index": None,
|
||||
"tag": None,
|
||||
"category": None,
|
||||
"author": None,
|
||||
},
|
||||
"PELICAN_CLASS": "pelican.Pelican",
|
||||
"DEFAULT_DATE_FORMAT": "%a %d %B %Y",
|
||||
"DATE_FORMATS": {},
|
||||
"MARKDOWN": {
|
||||
"extension_configs": {
|
||||
"markdown.extensions.codehilite": {"css_class": "highlight"},
|
||||
"markdown.extensions.extra": {},
|
||||
"markdown.extensions.meta": {},
|
||||
},
|
||||
"output_format": "html5",
|
||||
},
|
||||
"JINJA_FILTERS": {},
|
||||
"JINJA_GLOBALS": {},
|
||||
"JINJA_TESTS": {},
|
||||
"JINJA_ENVIRONMENT": {
|
||||
"trim_blocks": True,
|
||||
"lstrip_blocks": True,
|
||||
"extensions": [],
|
||||
},
|
||||
"LOG_FILTER": [],
|
||||
"LOCALE": [""], # defaults to user locale
|
||||
"DEFAULT_PAGINATION": False,
|
||||
"DEFAULT_ORPHANS": 0,
|
||||
"DEFAULT_METADATA": {},
|
||||
"FILENAME_METADATA": r"(?P<date>\d{4}-\d{2}-\d{2}).*",
|
||||
"PATH_METADATA": "",
|
||||
"EXTRA_PATH_METADATA": {},
|
||||
"ARTICLE_PERMALINK_STRUCTURE": "",
|
||||
"TYPOGRIFY": False,
|
||||
"TYPOGRIFY_IGNORE_TAGS": [],
|
||||
"TYPOGRIFY_DASHES": "default",
|
||||
"SUMMARY_END_SUFFIX": "…",
|
||||
"SUMMARY_MAX_LENGTH": 50,
|
||||
"PLUGIN_PATHS": [],
|
||||
"PLUGINS": None,
|
||||
"PYGMENTS_RST_OPTIONS": {},
|
||||
"TEMPLATE_PAGES": {},
|
||||
"TEMPLATE_EXTENSIONS": [".html"],
|
||||
"IGNORE_FILES": [".#*"],
|
||||
"SLUG_REGEX_SUBSTITUTIONS": [
|
||||
(r"[^\w\s-]", ""), # remove non-alphabetical/whitespace/'-' chars
|
||||
(r"(?u)\A\s*", ""), # strip leading whitespace
|
||||
(r"(?u)\s*\Z", ""), # strip trailing whitespace
|
||||
(r"[-\s]+", "-"), # reduce multiple whitespace or '-' to single '-'
|
||||
],
|
||||
"INTRASITE_LINK_REGEX": "[{|](?P<what>.*?)[|}]",
|
||||
"SLUGIFY_SOURCE": "title",
|
||||
"SLUGIFY_USE_UNICODE": False,
|
||||
"SLUGIFY_PRESERVE_CASE": False,
|
||||
"CACHE_CONTENT": False,
|
||||
"CONTENT_CACHING_LAYER": "reader",
|
||||
"CACHE_PATH": "cache",
|
||||
"GZIP_CACHE": True,
|
||||
"CHECK_MODIFIED_METHOD": "mtime",
|
||||
"LOAD_CONTENT_CACHE": False,
|
||||
"FORMATTED_FIELDS": ["summary"],
|
||||
"PORT": 8000,
|
||||
"BIND": "127.0.0.1",
|
||||
}
|
||||
|
||||
PYGMENTS_RST_OPTIONS = None
|
||||
|
||||
|
||||
def read_settings(
|
||||
path: Optional[str] = None, override: Optional[Settings] = None
|
||||
) -> Settings:
|
||||
settings = override or {}
|
||||
|
||||
if path:
|
||||
settings = dict(get_settings_from_file(path), **settings)
|
||||
|
||||
if settings:
|
||||
settings = handle_deprecated_settings(settings)
|
||||
|
||||
if path:
|
||||
# Make relative paths absolute
|
||||
def getabs(maybe_relative, base_path=path):
|
||||
if isabs(maybe_relative):
|
||||
return maybe_relative
|
||||
return os.path.abspath(
|
||||
os.path.normpath(
|
||||
os.path.join(os.path.dirname(base_path), maybe_relative)
|
||||
)
|
||||
)
|
||||
|
||||
for p in ["PATH", "OUTPUT_PATH", "THEME", "CACHE_PATH"]:
|
||||
if settings.get(p) is not None:
|
||||
absp = getabs(settings[p])
|
||||
# THEME may be a name rather than a path
|
||||
if p != "THEME" or os.path.exists(absp):
|
||||
settings[p] = absp
|
||||
|
||||
if settings.get("PLUGIN_PATHS") is not None:
|
||||
settings["PLUGIN_PATHS"] = [
|
||||
getabs(pluginpath) for pluginpath in settings["PLUGIN_PATHS"]
|
||||
]
|
||||
|
||||
settings = dict(copy.deepcopy(DEFAULT_CONFIG), **settings)
|
||||
settings = configure_settings(settings)
|
||||
|
||||
# This is because there doesn't seem to be a way to pass extra
|
||||
# parameters to docutils directive handlers, so we have to have a
|
||||
# variable here that we'll import from within Pygments.run (see
|
||||
# rstdirectives.py) to see what the user defaults were.
|
||||
global PYGMENTS_RST_OPTIONS # noqa: PLW0603
|
||||
PYGMENTS_RST_OPTIONS = settings.get("PYGMENTS_RST_OPTIONS", None)
|
||||
return settings
|
||||
|
||||
|
||||
def get_settings_from_module(module: Optional[ModuleType] = None) -> Settings:
|
||||
"""Loads settings from a module, returns a dictionary."""
|
||||
|
||||
context = {}
|
||||
if module is not None:
|
||||
context.update((k, v) for k, v in inspect.getmembers(module) if k.isupper())
|
||||
return context
|
||||
|
||||
|
||||
def get_settings_from_file(path: str) -> Settings:
|
||||
"""Loads settings from a file path, returning a dict."""
|
||||
|
||||
name, ext = os.path.splitext(os.path.basename(path))
|
||||
module = load_source(name, path)
|
||||
return get_settings_from_module(module)
|
||||
|
||||
|
||||
def get_jinja_environment(settings: Settings) -> Settings:
|
||||
"""Sets the environment for Jinja"""
|
||||
|
||||
jinja_env = settings.setdefault(
|
||||
"JINJA_ENVIRONMENT", DEFAULT_CONFIG["JINJA_ENVIRONMENT"]
|
||||
)
|
||||
|
||||
# Make sure we include the defaults if the user has set env variables
|
||||
for key, value in DEFAULT_CONFIG["JINJA_ENVIRONMENT"].items():
|
||||
if key not in jinja_env:
|
||||
jinja_env[key] = value
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def _printf_s_to_format_field(printf_string: str, format_field: str) -> str:
|
||||
"""Tries to replace %s with {format_field} in the provided printf_string.
|
||||
Raises ValueError in case of failure.
|
||||
"""
|
||||
TEST_STRING = "PELICAN_PRINTF_S_DEPRECATION"
|
||||
expected = printf_string % TEST_STRING
|
||||
|
||||
result = printf_string.replace("{", "{{").replace("}", "}}") % f"{{{format_field}}}"
|
||||
if result.format(**{format_field: TEST_STRING}) != expected:
|
||||
raise ValueError(f"Failed to safely replace %s with {{{format_field}}}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def handle_deprecated_settings(settings: Settings) -> Settings:
|
||||
"""Converts deprecated settings and issues warnings. Issues an exception
|
||||
if both old and new setting is specified.
|
||||
"""
|
||||
|
||||
# PLUGIN_PATH -> PLUGIN_PATHS
|
||||
if "PLUGIN_PATH" in settings:
|
||||
logger.warning(
|
||||
"PLUGIN_PATH setting has been replaced by "
|
||||
"PLUGIN_PATHS, moving it to the new setting name."
|
||||
)
|
||||
settings["PLUGIN_PATHS"] = settings["PLUGIN_PATH"]
|
||||
del settings["PLUGIN_PATH"]
|
||||
|
||||
# PLUGIN_PATHS: str -> [str]
|
||||
if isinstance(settings.get("PLUGIN_PATHS"), str):
|
||||
logger.warning(
|
||||
"Defining PLUGIN_PATHS setting as string "
|
||||
"has been deprecated (should be a list)"
|
||||
)
|
||||
settings["PLUGIN_PATHS"] = [settings["PLUGIN_PATHS"]]
|
||||
|
||||
# JINJA_EXTENSIONS -> JINJA_ENVIRONMENT > extensions
|
||||
if "JINJA_EXTENSIONS" in settings:
|
||||
logger.warning(
|
||||
"JINJA_EXTENSIONS setting has been deprecated, "
|
||||
"moving it to JINJA_ENVIRONMENT setting."
|
||||
)
|
||||
settings["JINJA_ENVIRONMENT"]["extensions"] = settings["JINJA_EXTENSIONS"]
|
||||
del settings["JINJA_EXTENSIONS"]
|
||||
|
||||
# {ARTICLE,PAGE}_DIR -> {ARTICLE,PAGE}_PATHS
|
||||
for key in ["ARTICLE", "PAGE"]:
|
||||
old_key = key + "_DIR"
|
||||
new_key = key + "_PATHS"
|
||||
if old_key in settings:
|
||||
logger.warning(
|
||||
"Deprecated setting %s, moving it to %s list", old_key, new_key
|
||||
)
|
||||
settings[new_key] = [settings[old_key]] # also make a list
|
||||
del settings[old_key]
|
||||
|
||||
# EXTRA_TEMPLATES_PATHS -> THEME_TEMPLATES_OVERRIDES
|
||||
if "EXTRA_TEMPLATES_PATHS" in settings:
|
||||
logger.warning(
|
||||
"EXTRA_TEMPLATES_PATHS is deprecated use "
|
||||
"THEME_TEMPLATES_OVERRIDES instead."
|
||||
)
|
||||
if settings.get("THEME_TEMPLATES_OVERRIDES"):
|
||||
raise Exception(
|
||||
"Setting both EXTRA_TEMPLATES_PATHS and "
|
||||
"THEME_TEMPLATES_OVERRIDES is not permitted. Please move to "
|
||||
"only setting THEME_TEMPLATES_OVERRIDES."
|
||||
)
|
||||
settings["THEME_TEMPLATES_OVERRIDES"] = settings["EXTRA_TEMPLATES_PATHS"]
|
||||
del settings["EXTRA_TEMPLATES_PATHS"]
|
||||
|
||||
# MD_EXTENSIONS -> MARKDOWN
|
||||
if "MD_EXTENSIONS" in settings:
|
||||
logger.warning(
|
||||
"MD_EXTENSIONS is deprecated use MARKDOWN "
|
||||
"instead. Falling back to the default."
|
||||
)
|
||||
settings["MARKDOWN"] = DEFAULT_CONFIG["MARKDOWN"]
|
||||
|
||||
# LESS_GENERATOR -> Webassets plugin
|
||||
# FILES_TO_COPY -> STATIC_PATHS, EXTRA_PATH_METADATA
|
||||
for old, new, doc in [
|
||||
("LESS_GENERATOR", "the Webassets plugin", None),
|
||||
(
|
||||
"FILES_TO_COPY",
|
||||
"STATIC_PATHS and EXTRA_PATH_METADATA",
|
||||
"https://github.com/getpelican/pelican/"
|
||||
"blob/main/docs/settings.rst#path-metadata",
|
||||
),
|
||||
]:
|
||||
if old in settings:
|
||||
message = f"The {old} setting has been removed in favor of {new}"
|
||||
if doc:
|
||||
message += f", see {doc} for details"
|
||||
logger.warning(message)
|
||||
|
||||
# PAGINATED_DIRECT_TEMPLATES -> PAGINATED_TEMPLATES
|
||||
if "PAGINATED_DIRECT_TEMPLATES" in settings:
|
||||
message = "The {} setting has been removed in favor of {}".format(
|
||||
"PAGINATED_DIRECT_TEMPLATES", "PAGINATED_TEMPLATES"
|
||||
)
|
||||
logger.warning(message)
|
||||
|
||||
# set PAGINATED_TEMPLATES
|
||||
if "PAGINATED_TEMPLATES" not in settings:
|
||||
settings["PAGINATED_TEMPLATES"] = {
|
||||
"tag": None,
|
||||
"category": None,
|
||||
"author": None,
|
||||
}
|
||||
|
||||
for t in settings["PAGINATED_DIRECT_TEMPLATES"]:
|
||||
if t not in settings["PAGINATED_TEMPLATES"]:
|
||||
settings["PAGINATED_TEMPLATES"][t] = None
|
||||
del settings["PAGINATED_DIRECT_TEMPLATES"]
|
||||
|
||||
# {SLUG,CATEGORY,TAG,AUTHOR}_SUBSTITUTIONS ->
|
||||
# {SLUG,CATEGORY,TAG,AUTHOR}_REGEX_SUBSTITUTIONS
|
||||
url_settings_url = "http://docs.getpelican.com/en/latest/settings.html#url-settings"
|
||||
flavours = {"SLUG", "CATEGORY", "TAG", "AUTHOR"}
|
||||
old_values = {
|
||||
f: settings[f + "_SUBSTITUTIONS"]
|
||||
for f in flavours
|
||||
if f + "_SUBSTITUTIONS" in settings
|
||||
}
|
||||
new_values = {
|
||||
f: settings[f + "_REGEX_SUBSTITUTIONS"]
|
||||
for f in flavours
|
||||
if f + "_REGEX_SUBSTITUTIONS" in settings
|
||||
}
|
||||
if old_values and new_values:
|
||||
raise Exception(
|
||||
"Setting both {new_key} and {old_key} (or variants thereof) is "
|
||||
"not permitted. Please move to only setting {new_key}.".format(
|
||||
old_key="SLUG_SUBSTITUTIONS", new_key="SLUG_REGEX_SUBSTITUTIONS"
|
||||
)
|
||||
)
|
||||
if old_values:
|
||||
message = (
|
||||
"{} and variants thereof are deprecated and will be "
|
||||
"removed in the future. Please use {} and variants thereof "
|
||||
"instead. Check {}.".format(
|
||||
"SLUG_SUBSTITUTIONS", "SLUG_REGEX_SUBSTITUTIONS", url_settings_url
|
||||
)
|
||||
)
|
||||
logger.warning(message)
|
||||
if old_values.get("SLUG"):
|
||||
for f in ("CATEGORY", "TAG"):
|
||||
if old_values.get(f):
|
||||
old_values[f] = old_values["SLUG"] + old_values[f]
|
||||
old_values["AUTHOR"] = old_values.get("AUTHOR", [])
|
||||
for f in flavours:
|
||||
if old_values.get(f) is not None:
|
||||
regex_subs = []
|
||||
# by default will replace non-alphanum characters
|
||||
replace = True
|
||||
for tpl in old_values[f]:
|
||||
try:
|
||||
src, dst, skip = tpl
|
||||
if skip:
|
||||
replace = False
|
||||
except ValueError:
|
||||
src, dst = tpl
|
||||
regex_subs.append((re.escape(src), dst.replace("\\", r"\\")))
|
||||
|
||||
if replace:
|
||||
regex_subs += [
|
||||
(r"[^\w\s-]", ""),
|
||||
(r"(?u)\A\s*", ""),
|
||||
(r"(?u)\s*\Z", ""),
|
||||
(r"[-\s]+", "-"),
|
||||
]
|
||||
else:
|
||||
regex_subs += [
|
||||
(r"(?u)\A\s*", ""),
|
||||
(r"(?u)\s*\Z", ""),
|
||||
]
|
||||
settings[f + "_REGEX_SUBSTITUTIONS"] = regex_subs
|
||||
settings.pop(f + "_SUBSTITUTIONS", None)
|
||||
|
||||
# `%s` -> '{slug}` or `{lang}` in FEED settings
|
||||
for key in ["TRANSLATION_FEED_ATOM", "TRANSLATION_FEED_RSS"]:
|
||||
if (
|
||||
settings.get(key)
|
||||
and not isinstance(settings[key], Path)
|
||||
and "%s" in settings[key]
|
||||
):
|
||||
logger.warning("%%s usage in %s is deprecated, use {lang} instead.", key)
|
||||
try:
|
||||
settings[key] = _printf_s_to_format_field(settings[key], "lang")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Failed to convert %%s to {lang} for %s. "
|
||||
"Falling back to default.",
|
||||
key,
|
||||
)
|
||||
settings[key] = DEFAULT_CONFIG[key]
|
||||
for key in [
|
||||
"AUTHOR_FEED_ATOM",
|
||||
"AUTHOR_FEED_RSS",
|
||||
"CATEGORY_FEED_ATOM",
|
||||
"CATEGORY_FEED_RSS",
|
||||
"TAG_FEED_ATOM",
|
||||
"TAG_FEED_RSS",
|
||||
]:
|
||||
if (
|
||||
settings.get(key)
|
||||
and not isinstance(settings[key], Path)
|
||||
and "%s" in settings[key]
|
||||
):
|
||||
logger.warning("%%s usage in %s is deprecated, use {slug} instead.", key)
|
||||
try:
|
||||
settings[key] = _printf_s_to_format_field(settings[key], "slug")
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Failed to convert %%s to {slug} for %s. "
|
||||
"Falling back to default.",
|
||||
key,
|
||||
)
|
||||
settings[key] = DEFAULT_CONFIG[key]
|
||||
|
||||
# CLEAN_URLS
|
||||
if settings.get("CLEAN_URLS", False):
|
||||
logger.warning(
|
||||
"Found deprecated `CLEAN_URLS` in settings."
|
||||
" Modifying the following settings for the"
|
||||
" same behaviour."
|
||||
)
|
||||
|
||||
settings["ARTICLE_URL"] = "{slug}/"
|
||||
settings["ARTICLE_LANG_URL"] = "{slug}-{lang}/"
|
||||
settings["PAGE_URL"] = "pages/{slug}/"
|
||||
settings["PAGE_LANG_URL"] = "pages/{slug}-{lang}/"
|
||||
|
||||
for setting in ("ARTICLE_URL", "ARTICLE_LANG_URL", "PAGE_URL", "PAGE_LANG_URL"):
|
||||
logger.warning("%s = '%s'", setting, settings[setting])
|
||||
|
||||
# AUTORELOAD_IGNORE_CACHE -> --ignore-cache
|
||||
if settings.get("AUTORELOAD_IGNORE_CACHE"):
|
||||
logger.warning(
|
||||
"Found deprecated `AUTORELOAD_IGNORE_CACHE` in "
|
||||
"settings. Use --ignore-cache instead."
|
||||
)
|
||||
settings.pop("AUTORELOAD_IGNORE_CACHE")
|
||||
|
||||
# ARTICLE_PERMALINK_STRUCTURE
|
||||
if settings.get("ARTICLE_PERMALINK_STRUCTURE", False):
|
||||
logger.warning(
|
||||
"Found deprecated `ARTICLE_PERMALINK_STRUCTURE` in"
|
||||
" settings. Modifying the following settings for"
|
||||
" the same behaviour."
|
||||
)
|
||||
|
||||
structure = settings["ARTICLE_PERMALINK_STRUCTURE"]
|
||||
|
||||
# Convert %(variable) into {variable}.
|
||||
structure = re.sub(r"%\((\w+)\)s", r"{\g<1>}", structure)
|
||||
|
||||
# Convert %x into {date:%x} for strftime
|
||||
structure = re.sub(r"(%[A-z])", r"{date:\g<1>}", structure)
|
||||
|
||||
# Strip a / prefix
|
||||
structure = re.sub("^/", "", structure)
|
||||
|
||||
for setting in (
|
||||
"ARTICLE_URL",
|
||||
"ARTICLE_LANG_URL",
|
||||
"PAGE_URL",
|
||||
"PAGE_LANG_URL",
|
||||
"DRAFT_URL",
|
||||
"DRAFT_LANG_URL",
|
||||
"ARTICLE_SAVE_AS",
|
||||
"ARTICLE_LANG_SAVE_AS",
|
||||
"DRAFT_SAVE_AS",
|
||||
"DRAFT_LANG_SAVE_AS",
|
||||
"PAGE_SAVE_AS",
|
||||
"PAGE_LANG_SAVE_AS",
|
||||
):
|
||||
settings[setting] = os.path.join(structure, settings[setting])
|
||||
logger.warning("%s = '%s'", setting, settings[setting])
|
||||
|
||||
# {,TAG,CATEGORY,TRANSLATION}_FEED -> {,TAG,CATEGORY,TRANSLATION}_FEED_ATOM
|
||||
for new, old in [
|
||||
("FEED", "FEED_ATOM"),
|
||||
("TAG_FEED", "TAG_FEED_ATOM"),
|
||||
("CATEGORY_FEED", "CATEGORY_FEED_ATOM"),
|
||||
("TRANSLATION_FEED", "TRANSLATION_FEED_ATOM"),
|
||||
]:
|
||||
if settings.get(new, False):
|
||||
logger.warning(
|
||||
"Found deprecated `%(new)s` in settings. Modify %(new)s "
|
||||
"to %(old)s in your settings and theme for the same "
|
||||
"behavior. Temporarily setting %(old)s for backwards "
|
||||
"compatibility.",
|
||||
{"new": new, "old": old},
|
||||
)
|
||||
settings[old] = settings[new]
|
||||
|
||||
# Warn if removed WRITE_SELECTED is present
|
||||
if "WRITE_SELECTED" in settings:
|
||||
logger.warning(
|
||||
"WRITE_SELECTED is present in settings but this functionality was removed. "
|
||||
"It will have no effect."
|
||||
)
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
def configure_settings(settings: Settings) -> Settings:
|
||||
"""Provide optimizations, error checking, and warnings for the given
|
||||
settings.
|
||||
Also, specify the log messages to be ignored.
|
||||
"""
|
||||
if "PATH" not in settings or not os.path.isdir(settings["PATH"]):
|
||||
raise Exception(
|
||||
"You need to specify a path containing the content"
|
||||
" (see pelican --help for more information)"
|
||||
)
|
||||
|
||||
# specify the log messages to be ignored
|
||||
log_filter = settings.get("LOG_FILTER", DEFAULT_CONFIG["LOG_FILTER"])
|
||||
LimitFilter._ignore.update(set(log_filter))
|
||||
|
||||
# lookup the theme in "pelican/themes" if the given one doesn't exist
|
||||
if not os.path.isdir(settings["THEME"]):
|
||||
theme_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)), "themes", settings["THEME"]
|
||||
)
|
||||
if os.path.exists(theme_path):
|
||||
settings["THEME"] = theme_path
|
||||
else:
|
||||
raise Exception("Could not find the theme {}".format(settings["THEME"]))
|
||||
|
||||
# standardize strings to lowercase strings
|
||||
for key in ["DEFAULT_LANG"]:
|
||||
if key in settings:
|
||||
settings[key] = settings[key].lower()
|
||||
|
||||
# set defaults for Jinja environment
|
||||
settings = get_jinja_environment(settings)
|
||||
|
||||
# standardize strings to lists
|
||||
for key in ["LOCALE"]:
|
||||
if key in settings and isinstance(settings[key], str):
|
||||
settings[key] = [settings[key]]
|
||||
|
||||
# check settings that must be a particular type
|
||||
for key, types in [
|
||||
("OUTPUT_SOURCES_EXTENSION", str),
|
||||
("FILENAME_METADATA", str),
|
||||
]:
|
||||
if key in settings and not isinstance(settings[key], types):
|
||||
value = settings.pop(key)
|
||||
logger.warn(
|
||||
"Detected misconfigured %s (%s), falling back to the default (%s)",
|
||||
key,
|
||||
value,
|
||||
DEFAULT_CONFIG[key],
|
||||
)
|
||||
|
||||
# try to set the different locales, fallback on the default.
|
||||
locales = settings.get("LOCALE", DEFAULT_CONFIG["LOCALE"])
|
||||
|
||||
for locale_ in locales:
|
||||
try:
|
||||
locale.setlocale(locale.LC_ALL, str(locale_))
|
||||
break # break if it is successful
|
||||
except locale.Error:
|
||||
pass
|
||||
else:
|
||||
logger.warning(
|
||||
"Locale could not be set. Check the LOCALE setting, ensuring it "
|
||||
"is valid and available on your system."
|
||||
)
|
||||
|
||||
if "SITEURL" in settings:
|
||||
# If SITEURL has a trailing slash, remove it and provide a warning
|
||||
siteurl = settings["SITEURL"]
|
||||
if siteurl.endswith("/"):
|
||||
settings["SITEURL"] = siteurl[:-1]
|
||||
logger.warning("Removed extraneous trailing slash from SITEURL.")
|
||||
# If SITEURL is defined but FEED_DOMAIN isn't,
|
||||
# set FEED_DOMAIN to SITEURL
|
||||
if "FEED_DOMAIN" not in settings:
|
||||
settings["FEED_DOMAIN"] = settings["SITEURL"]
|
||||
|
||||
# check content caching layer and warn of incompatibilities
|
||||
if (
|
||||
settings.get("CACHE_CONTENT", False)
|
||||
and settings.get("CONTENT_CACHING_LAYER", "") == "generator"
|
||||
and not settings.get("WITH_FUTURE_DATES", True)
|
||||
):
|
||||
logger.warning(
|
||||
"WITH_FUTURE_DATES conflicts with CONTENT_CACHING_LAYER "
|
||||
"set to 'generator', use 'reader' layer instead"
|
||||
)
|
||||
|
||||
# Warn if feeds are generated with both SITEURL & FEED_DOMAIN undefined
|
||||
feed_keys = [
|
||||
"FEED_ATOM",
|
||||
"FEED_RSS",
|
||||
"FEED_ALL_ATOM",
|
||||
"FEED_ALL_RSS",
|
||||
"CATEGORY_FEED_ATOM",
|
||||
"CATEGORY_FEED_RSS",
|
||||
"AUTHOR_FEED_ATOM",
|
||||
"AUTHOR_FEED_RSS",
|
||||
"TAG_FEED_ATOM",
|
||||
"TAG_FEED_RSS",
|
||||
"TRANSLATION_FEED_ATOM",
|
||||
"TRANSLATION_FEED_RSS",
|
||||
]
|
||||
|
||||
if any(settings.get(k) for k in feed_keys):
|
||||
if not settings.get("SITEURL"):
|
||||
logger.warning(
|
||||
"Feeds generated without SITEURL set properly may not be valid"
|
||||
)
|
||||
|
||||
if "TIMEZONE" not in settings:
|
||||
logger.warning(
|
||||
"No timezone information specified in the settings. Assuming"
|
||||
" your timezone is UTC for feed generation. Check "
|
||||
"https://docs.getpelican.com/en/latest/settings.html#TIMEZONE "
|
||||
"for more information"
|
||||
)
|
||||
|
||||
# fix up pagination rules
|
||||
from pelican.paginator import PaginationRule
|
||||
|
||||
pagination_rules = [
|
||||
PaginationRule(*r)
|
||||
for r in settings.get(
|
||||
"PAGINATION_PATTERNS",
|
||||
DEFAULT_CONFIG["PAGINATION_PATTERNS"],
|
||||
)
|
||||
]
|
||||
settings["PAGINATION_PATTERNS"] = sorted(
|
||||
pagination_rules,
|
||||
key=lambda r: r[0],
|
||||
)
|
||||
|
||||
# Save people from accidentally setting a string rather than a list
|
||||
path_keys = (
|
||||
"ARTICLE_EXCLUDES",
|
||||
"DEFAULT_METADATA",
|
||||
"DIRECT_TEMPLATES",
|
||||
"THEME_TEMPLATES_OVERRIDES",
|
||||
"FILES_TO_COPY",
|
||||
"IGNORE_FILES",
|
||||
"PAGINATED_DIRECT_TEMPLATES",
|
||||
"PLUGINS",
|
||||
"STATIC_EXCLUDES",
|
||||
"STATIC_PATHS",
|
||||
"THEME_STATIC_PATHS",
|
||||
"ARTICLE_PATHS",
|
||||
"PAGE_PATHS",
|
||||
)
|
||||
for PATH_KEY in filter(lambda k: k in settings, path_keys):
|
||||
if isinstance(settings[PATH_KEY], str):
|
||||
logger.warning(
|
||||
"Detected misconfiguration with %s setting "
|
||||
"(must be a list), falling back to the default",
|
||||
PATH_KEY,
|
||||
)
|
||||
settings[PATH_KEY] = DEFAULT_CONFIG[PATH_KEY]
|
||||
|
||||
# Add {PAGE,ARTICLE}_PATHS to {ARTICLE,PAGE}_EXCLUDES
|
||||
mutually_exclusive = ("ARTICLE", "PAGE")
|
||||
for type_1, type_2 in [mutually_exclusive, mutually_exclusive[::-1]]:
|
||||
try:
|
||||
includes = settings[type_1 + "_PATHS"]
|
||||
excludes = settings[type_2 + "_EXCLUDES"]
|
||||
for path in includes:
|
||||
if path not in excludes:
|
||||
excludes.append(path)
|
||||
except KeyError:
|
||||
continue # setting not specified, nothing to do
|
||||
|
||||
return settings
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
raise ImportError(
|
||||
"Importing from `pelican.signals` is deprecated. "
|
||||
"Use `from pelican import signals` or `import pelican.plugins.signals` instead."
|
||||
)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
This is a test bad page
|
||||
#######################
|
||||
|
||||
:status: invalid
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
||||
The status here is invalid, the page should not render.
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
This is a test draft page
|
||||
##########################
|
||||
|
||||
:status: draft
|
||||
|
||||
The quick brown fox .
|
||||
|
||||
This page is a draft.
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
title: This is a markdown test draft page
|
||||
status: draft
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
The quick brown fox .
|
||||
|
||||
This page is a draft
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
This is a test draft page with a custom template
|
||||
#################################################
|
||||
|
||||
:status: draft
|
||||
:template: custom
|
||||
|
||||
The quick brown fox .
|
||||
|
||||
This page is a draft
|
||||
|
||||
This page has a custom template to be called when rendered
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
This is a test hidden page
|
||||
##########################
|
||||
|
||||
:status: hidden
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
||||
This page is hidden
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
title: This is a markdown test hidden page
|
||||
status: hidden
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
||||
This page is hidden
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
This is a test hidden page with a custom template
|
||||
#################################################
|
||||
|
||||
:status: hidden
|
||||
:template: custom
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
||||
This page is hidden
|
||||
|
||||
This page has a custom template to be called when rendered
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
This is a test page
|
||||
###################
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
title: This is a markdown test page
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
A Page (Test) for sorting
|
||||
#########################
|
||||
|
||||
:slug: zzzz
|
||||
|
||||
When using title, should be first. When using slug, should be last.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
Title: Page with a bunch of links
|
||||
|
||||
My links:
|
||||
|
||||
[Link 1]({tag}マック)
|
||||
|
||||
[Link 2]({category}Yeah)
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
Title: Page with static links
|
||||
|
||||
My links:
|
||||
|
||||
[Link 0]({static}image0.jpg)
|
||||
|
||||
[Link 1]({attach}image1.jpg)
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
This is a test page with a preset template
|
||||
##########################################
|
||||
|
||||
:template: custom
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
||||
This article has a custom template to be called when rendered
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import logging
|
||||
import warnings
|
||||
|
||||
from pelican.log import log_warnings
|
||||
|
||||
# redirect warnings module to use logging instead
|
||||
log_warnings()
|
||||
|
||||
# setup warnings to log DeprecationWarning's and error on
|
||||
# warnings in pelican's codebase
|
||||
warnings.simplefilter("default", DeprecationWarning)
|
||||
warnings.filterwarnings("error", ".*", Warning, "pelican")
|
||||
|
||||
# Add a NullHandler to silence warning about no available handlers
|
||||
logging.getLogger().addHandler(logging.NullHandler())
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
def pytest_addoption(parser):
|
||||
parser.addoption(
|
||||
"--check-build",
|
||||
action="store",
|
||||
default=False,
|
||||
help="Check wheel contents.",
|
||||
)
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import importlib.metadata
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from re import match
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
|
||||
version = importlib.metadata.version("pelican")
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--check-build')",
|
||||
reason="Only run when --check-build is given",
|
||||
)
|
||||
def test_wheel_contents(pytestconfig):
|
||||
"""
|
||||
This test should test the contents of the wheel to make sure
|
||||
that everything that is needed is included in the final build
|
||||
"""
|
||||
dist_folder = pytestconfig.getoption("--check-build")
|
||||
wheels = Path(dist_folder).rglob(f"pelican-{version}-py3-none-any.whl")
|
||||
for wheel_file in wheels:
|
||||
files_list = ZipFile(wheel_file).namelist()
|
||||
# Check if theme files are copied to wheel
|
||||
simple_theme = Path("./pelican/themes/simple/templates")
|
||||
for x in simple_theme.iterdir():
|
||||
assert str(x) in files_list
|
||||
|
||||
# Check if tool templates are copied to wheel
|
||||
tools = Path("./pelican/tools/templates")
|
||||
for x in tools.iterdir():
|
||||
assert str(x) in files_list
|
||||
|
||||
assert "pelican/tools/templates/tasks.py.jinja2" in files_list
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
"not config.getoption('--check-build')",
|
||||
reason="Only run when --check-build is given",
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"expected_file",
|
||||
[
|
||||
("THANKS"),
|
||||
("README.rst"),
|
||||
("CONTRIBUTING.rst"),
|
||||
("docs/changelog.rst"),
|
||||
("samples/"),
|
||||
],
|
||||
)
|
||||
def test_sdist_contents(pytestconfig, expected_file):
|
||||
"""
|
||||
This test should test the contents of the source distribution to make sure
|
||||
that everything that is needed is included in the final build.
|
||||
"""
|
||||
dist_folder = pytestconfig.getoption("--check-build")
|
||||
sdist_files = Path(dist_folder).rglob(f"pelican-{version}.tar.gz")
|
||||
for dist in sdist_files:
|
||||
files_list = tarfile.open(dist, "r:gz").getnames()
|
||||
dir_matcher = ""
|
||||
if expected_file.endswith("/"):
|
||||
dir_matcher = ".*"
|
||||
filtered_values = [
|
||||
path
|
||||
for path in files_list
|
||||
if match(rf"^pelican-{version}/{expected_file}{dir_matcher}$", path)
|
||||
]
|
||||
assert len(filtered_values) > 0
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
|
||||
Rst with filename metadata
|
||||
##########################
|
||||
|
||||
:category: yeah
|
||||
:author: Alexis Métaireau
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
category: yeah
|
||||
author: Alexis Métaireau
|
||||
|
||||
Markdown with filename metadata
|
||||
===============================
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
This is an article with category !
|
||||
##################################
|
||||
|
||||
:category: yeah
|
||||
:date: 1970-01-01
|
||||
|
||||
This article should be in 'yeah' category.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
This is an article without category !
|
||||
#####################################
|
||||
|
||||
This article should be in 'TestCategory' category.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
Article title
|
||||
#############
|
||||
|
||||
THIS is some content. With some stuff to "typogrify"...
|
||||
|
||||
Now with added support for :abbr:`TLA (three letter acronym)`.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Title: Draft article
|
||||
Date: 2012-10-31
|
||||
Status: draft
|
||||
|
||||
This is some content.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Title: Hidden article
|
||||
Date: 2012-10-31
|
||||
Status: hidden
|
||||
|
||||
This is some unlisted content.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Title: Skipped article
|
||||
Date: 2024-06-30
|
||||
Status: skip
|
||||
|
||||
This content will not be rendered.
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
Ensure that if an attribute value contains a double quote, it is
|
||||
surrounded with single quotes, otherwise with double quotes.
|
||||
<span data-test="'single quoted string'">Span content</span>
|
||||
<span data-test='"double quoted string"'>Span content</span>
|
||||
<span data-test="string without quotes">Span content</span>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
|
||||
This is a super article !
|
||||
#########################
|
||||
|
||||
:TAGS: foo, bar, foobar
|
||||
:DATE: 2010-12-02 10:14
|
||||
:MODIFIED: 2010-12-02 10:20
|
||||
:CATEGORY: yeah
|
||||
:AUTHOR: Alexis Métaireau
|
||||
:SUMMARY:
|
||||
Multi-line metadata should be supported
|
||||
as well as **inline markup** and stuff to "typogrify"...
|
||||
:CUSTOM_FIELD: http://notmyidea.org
|
||||
:CUSTOM_FORMATTED_FIELD:
|
||||
Multi-line metadata should also be supported
|
||||
as well as *inline markup* and stuff to "typogrify"...
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
An Article With Code Block To Test Typogrify Ignore
|
||||
###################################################
|
||||
|
||||
An article with some code
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
x & y
|
||||
|
||||
A block quote:
|
||||
|
||||
x & y
|
||||
|
||||
Normal:
|
||||
x & y
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
Body content
|
||||
<!-- This comment is included (including extra whitespace) -->
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
Title: Test metadata duplicates
|
||||
Category: test
|
||||
Tags: foo, bar, foobar, foo, bar
|
||||
Authors: Author, First; Author, Second; Author, First
|
||||
Date: 2010-12-02 10:14
|
||||
Modified: 2010-12-02 10:20
|
||||
Summary: I have a lot to test
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Article with an inline SVG</title>
|
||||
</head>
|
||||
<body>
|
||||
Ensure that the title attribute in an inline svg is not handled as an HTML title.
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="210mm" height="297mm" viewBox="0 0 210 297">
|
||||
<path fill="#b2b2ff" stroke="#000" stroke-width="2.646" d="M88.698 89.869l-8.899 15.63a38.894 38.894 0 00-16.474 31.722 38.894 38.894 0 0038.894 38.894 38.894 38.894 0 0038.894-38.894 38.894 38.894 0 00-9-24.83l-2.38-16.886-14.828 4.994a38.894 38.894 0 00-12.13-2.144z">
|
||||
<title>A different title inside the inline SVG</title>
|
||||
</path>
|
||||
<ellipse cx="100.806" cy="125.285" rx="3.704" ry="10.583"/>
|
||||
<ellipse cx="82.021" cy="125.285" rx="3.704" ry="10.583"/>
|
||||
<ellipse cx="-111.432" cy="146.563" rx="3.704" ry="10.583" transform="rotate(-64.822)"/>
|
||||
<ellipse cx="-118.245" cy="91.308" rx="6.18" ry="8.62" transform="matrix(.063 -.99801 .96163 .27436 0 0)"/>
|
||||
</svg>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="keywords" content="foo, bar, foobar" />
|
||||
</head>
|
||||
</html>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
Title: Article with markdown and empty tags
|
||||
Tags:
|
||||
|
||||
This is some content.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
Title: Article with markdown containing footnotes
|
||||
Date: 2012-10-31
|
||||
Modified: 2012-11-01
|
||||
Summary: Summary with **inline** markup *should* be supported.
|
||||
Multiline: Line Metadata should be handle properly.
|
||||
See syntax of Meta-Data extension of Python Markdown package:
|
||||
If a line is indented by 4 or more spaces,
|
||||
that line is assumed to be an additional line of the value
|
||||
for the previous keyword.
|
||||
A keyword may have as many lines as desired.
|
||||
|
||||
This is some content[^1] with some footnotes[^footnote]
|
||||
|
||||
[^1]: Numbered footnote
|
||||
[^footnote]: Named footnote
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Title: Article with markdown and nested summary metadata
|
||||
Date: 2012-10-30
|
||||
Summary: Test: This metadata value looks like metadata
|
||||
|
||||
This is some content.
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
Title: マックOS X 10.8でパイソンとVirtualenvをインストールと設定
|
||||
Slug: python-virtualenv-on-mac-osx-mountain-lion-10.8
|
||||
Date: 2012-12-20
|
||||
Modified: 2012-12-22
|
||||
Tags: パイソン, マック
|
||||
Category: 指導書
|
||||
Summary: パイソンとVirtualenvをまっくでインストールする方法について明確に説明します。
|
||||
|
||||
Writing unicode is certainly fun.
|
||||
|
||||
パイソンとVirtualenvをまっくでインストールする方法について明確に説明します。
|
||||
|
||||
And let's mix languages.
|
||||
|
||||
первый пост
|
||||
|
||||
Now another.
|
||||
|
||||
İlk yazı çok özel değil.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
Title: Article with markdown and summary metadata multi
|
||||
Date: 2012-10-31
|
||||
Summary:
|
||||
A multi-line summary should be supported
|
||||
as well as **inline markup**.
|
||||
custom_formatted_field:
|
||||
Multi-line metadata should also be supported
|
||||
as well as *inline markup* and stuff to "typogrify"...
|
||||
|
||||
This is some content.
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
Title: Article with markdown and summary metadata single
|
||||
Date: 2012-10-30
|
||||
Summary: A single-line summary should be supported as well as **inline markup**.
|
||||
|
||||
This is some content.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
title: Test markdown File
|
||||
category: test
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
This is another markdown test file. Uses the markdown extension.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
Title: Test Markdown extensions
|
||||
|
||||
[TOC]
|
||||
|
||||
## Level1
|
||||
|
||||
### Level2
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
Title: Test md File
|
||||
Category: test
|
||||
Tags: foo, bar, foobar
|
||||
Date: 2010-12-02 10:14
|
||||
Modified: 2010-12-02 10:20
|
||||
Summary: I have a lot to test
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
The quick brown fox jumped over the lazy dog's back.
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
title: Test mdown File
|
||||
category: test
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
This is another markdown test file. Uses the mdown extension.
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="tags" content="foo, bar, foobar" />
|
||||
<meta name="date" content="2010-12-02 10:14" />
|
||||
<meta name="category" content="yeah" />
|
||||
<meta name="author" content="Alexis Métaireau" />
|
||||
<meta name="summary" content="Summary and stuff" />
|
||||
<meta name="custom_field" content="http://notmyidea.org" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
|
||||
This is a super article !
|
||||
#########################
|
||||
|
||||
:tags: foo, bar, foobar
|
||||
:date: 2010-12-02 10:14
|
||||
:modified: 2010-12-02 10:20
|
||||
:category: yeah
|
||||
:author: Alexis Métaireau
|
||||
:summary:
|
||||
Multi-line metadata should be supported
|
||||
as well as **inline markup** and stuff to "typogrify"...
|
||||
:custom_field: http://notmyidea.org
|
||||
:custom_formatted_field:
|
||||
Multi-line metadata should also be supported
|
||||
as well as *inline markup* and stuff to "typogrify"...
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
|
||||
This is a super article !
|
||||
#########################
|
||||
|
||||
:tags: foo, bar, foobar
|
||||
:date: 2010-12-02 10:14
|
||||
:category: yeah
|
||||
:author: Alexis Métaireau
|
||||
:summary:
|
||||
Multi-line metadata should be supported
|
||||
as well as **inline markup**.
|
||||
:custom_field: http://notmyidea.org
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="tags" contents="foo, bar, foobar" />
|
||||
<meta name="date" contents="2010-12-02 10:14" />
|
||||
<meta name="category" contents="yeah" />
|
||||
<meta name="author" contents="Alexis Métaireau" />
|
||||
<meta name="summary" contents="Summary and stuff" />
|
||||
<meta name="custom_field" contents="http://notmyidea.org" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="tags" content="foo, bar, foobar" />
|
||||
<meta name="date" content="2010-12-02 10:14" />
|
||||
<meta name="category" content="yeah" />
|
||||
<meta name="author" content="Alexis Métaireau" />
|
||||
<meta name="summary" content="Summary and stuff" />
|
||||
<meta name="custom_field" content="http://notmyidea.org" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="tags" content="foo, bar, foobar" />
|
||||
<meta name="date" content="2010-12-02 10:14" />
|
||||
<meta name="modified" content="2010-12-31 23:59" />
|
||||
<meta name="category" content="yeah" />
|
||||
<meta name="author" content="Alexis Métaireau" />
|
||||
<meta name="summary" content="Summary and stuff" />
|
||||
<meta name="custom_field" content="http://notmyidea.org" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="tags" content="foo, bar, foobar" />
|
||||
<meta name="modified" content="2010-12-02 10:14" />
|
||||
<meta name="category" content="yeah" />
|
||||
<meta name="author" content="Alexis Métaireau" />
|
||||
<meta name="summary" content="Summary and stuff" />
|
||||
<meta name="custom_field" content="http://notmyidea.org" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="tags" content="foo, bar, foobar" />
|
||||
<meta name="category" content="yeah" />
|
||||
<meta name="author" content="Alexis Métaireau" />
|
||||
<meta name="summary" content="Summary and stuff" />
|
||||
<meta name="custom_field" content="http://notmyidea.org" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
title: Test mkd File
|
||||
category: test
|
||||
|
||||
Test Markdown File Header
|
||||
=========================
|
||||
|
||||
Used for pelican test
|
||||
---------------------
|
||||
|
||||
This is another markdown test file. Uses the mkd extension.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is an article with multiple authors!</title>
|
||||
<meta name="authors" content="First Author, Second Author" />
|
||||
</head>
|
||||
</html>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
This is an article with multiple authors!
|
||||
#########################################
|
||||
|
||||
:date: 2014-02-09 02:20
|
||||
:modified: 2014-02-09 02:20
|
||||
:authors: First Author, Second Author
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
This is an article with multiple authors in list format!
|
||||
########################################################
|
||||
|
||||
:date: 2014-02-09 02:20
|
||||
:modified: 2014-02-09 02:20
|
||||
:authors: - Author, First
|
||||
- Author, Second
|
||||
|
||||
The author names are in last,first form to verify that
|
||||
they are not just getting split on commas.
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
This is an article with multiple authors in lastname, firstname format!
|
||||
#######################################################################
|
||||
|
||||
:date: 2014-02-09 02:20
|
||||
:modified: 2014-02-09 02:20
|
||||
:authors: Author, First; Author, Second
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Metadata tags as list!</title>
|
||||
<meta name="custom_field" content="https://getpelican.com" />
|
||||
<meta name="custom_field" content="https://www.eff.org" />
|
||||
</head>
|
||||
<body>
|
||||
When custom metadata tags are specified more than once
|
||||
they are collected into a list!
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<title>Article with Nonconformant HTML meta tags</title>
|
||||
<meta name="summary" content="Summary and stuff" />
|
||||
</head>
|
||||
<body>
|
||||
Multi-line metadata should be supported
|
||||
as well as <strong>inline markup</strong>.
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
</head>
|
||||
<body>
|
||||
Ensure that empty attributes are copied properly.
|
||||
<input name="test" disabled style="" />
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
Article with template
|
||||
#####################
|
||||
|
||||
:template: custom
|
||||
|
||||
This article has a custom template to be called when rendered
|
||||
|
||||
This is some content. With some stuff to "typogrify".
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Title: One -, two --, three --- dashes!
|
||||
|
||||
One: -; Two: --; Three: ---
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
One -, two --, three --- dashes!
|
||||
################################
|
||||
|
||||
One: -; Two: --; Three: ---
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>This is a super article !</title>
|
||||
<meta name="Category" content="Yeah" />
|
||||
</head>
|
||||
</html>
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
This is a super article !
|
||||
#########################
|
||||
|
||||
:Category: Yeah
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
|
||||
This is an article without category !
|
||||
#####################################
|
||||
|
||||
This article should be in the DEFAULT_CATEGORY.
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Title: Bad Extension
|
||||
|
||||
This file shouldn't be included because its file extension is `.mmd`.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1 +0,0 @@
|
|||
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
|
||||
<hr/><h3>Title header</h3><p>A paragraph of content.</p><p>Paragraph number two.</p><p>A list:</p><ol><li>One.</li><li>Two.</li><li>Three.</li></ol><p>A link: <a data-href="https://example.com/example" href="https://example.com/example" target="_blank">link text</a>.</p><h3>Header 2</h3><p>A block quote:</p><blockquote>quote words <strong>strong words</strong></blockquote><p>after blockquote</p><figure><img data-height="282" data-image-id="image1.png" data-width="739" src="https://cdn-images-1.medium.com/max/800/image1.png"/><figcaption>A figure caption.</figcaption></figure><p>A final note: <a data-href="http://stats.stackexchange.com/" href="http://stats.stackexchange.com/" rel="noopener" target="_blank">Cross-Validated</a> has sometimes been helpful.</p><hr/><p><em>Next: </em><a data-href="https://medium.com/@username/post-url" href="https://medium.com/@username/post-url" target="_blank"><em>Next post</em>
|
||||
</a></p>
|
||||
<p>By <a href="https://medium.com/@username">User Name</a> on <a href="https://medium.com/p/medium-short-url"><time datetime="2017-04-21T17:11:55.799Z">April 21, 2017</time></a>.</p><p><a href="https://medium.com/@username/this-post-url">Canonical link</a></p><p>Exported from <a href="https://medium.com">Medium</a> on December 1, 2023.</p>
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<!DOCTYPE html><html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"><title>A title</title><style>
|
||||
* {
|
||||
font-family: Georgia, Cambria, "Times New Roman", Times, serif;
|
||||
}
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 50px;
|
||||
margin-bottom: 17px;
|
||||
color: #333;
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 1.6;
|
||||
margin: 30px 0 0 0;
|
||||
margin-bottom: 18px;
|
||||
margin-top: 33px;
|
||||
color: #333;
|
||||
}
|
||||
h3 {
|
||||
font-size: 30px;
|
||||
margin: 10px 0 20px 0;
|
||||
color: #333;
|
||||
}
|
||||
header {
|
||||
width: 640px;
|
||||
margin: auto;
|
||||
}
|
||||
section {
|
||||
width: 640px;
|
||||
margin: auto;
|
||||
}
|
||||
section p {
|
||||
margin-bottom: 27px;
|
||||
font-size: 20px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
section img {
|
||||
max-width: 640px;
|
||||
}
|
||||
footer {
|
||||
padding: 0 20px;
|
||||
margin: 50px 0;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
}
|
||||
.aspectRatioPlaceholder {
|
||||
max-width: auto !important;
|
||||
max-height: auto !important;
|
||||
}
|
||||
.aspectRatioPlaceholder-fill {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
header,
|
||||
section[data-field=subtitle],
|
||||
section[data-field=description] {
|
||||
display: none;
|
||||
}
|
||||
</style></head><body><article class="h-entry">
|
||||
<header>
|
||||
<h1 class="p-name">A name (like title)</h1>
|
||||
</header>
|
||||
<section data-field="subtitle" class="p-summary">
|
||||
Summary (first several words of content)
|
||||
</section>
|
||||
<section data-field="body" class="e-content">
|
||||
<section name="ad15" class="section section--body section--first"><div class="section-divider"><hr class="section-divider"></div><div class="section-content"><div class="section-inner sectionLayout--insetColumn"><h3 name="20a3" id="20a3" class="graf graf--h3 graf--leading graf--title">Title header</h3><p name="e3d6" id="e3d6" class="graf graf--p graf-after--h3">A paragraph of content.</p><p name="c7a8" id="c7a8" class="graf graf--p graf-after--p">Paragraph number two.</p><p name="42aa" id="42aa" class="graf graf--p graf-after--p">A list:</p><ol class="postList"><li name="d65f" id="d65f" class="graf graf--li graf-after--p">One.</li><li name="232b" id="232b" class="graf graf--li graf-after--li">Two.</li><li name="ef87" id="ef87" class="graf graf--li graf-after--li">Three.</li></ol><p name="e743" id="e743" class="graf graf--p graf-after--p">A link: <a href="https://example.com/example" data-href="https://example.com/example" class="markup--anchor markup--p-anchor" target="_blank">link text</a>.</p><h3 name="4cfd" id="4cfd" class="graf graf--h3 graf-after--p">Header 2</h3><p name="433c" id="433c" class="graf graf--p graf-after--p">A block quote:</p><blockquote name="3537" id="3537" class="graf graf--blockquote graf-after--p">quote words <strong class="markup--strong markup--blockquote-strong">strong words</strong></blockquote><p name="00cc" id="00cc" class="graf graf--p graf-after--blockquote">after blockquote</p><figure name="edb0" id="edb0" class="graf graf--figure graf-after--p"><img class="graf-image" data-image-id="image1.png" data-width="739" data-height="282" src="https://cdn-images-1.medium.com/max/800/image1.png"><figcaption class="imageCaption">A figure caption.</figcaption></figure><p name="f401" id="f401" class="graf graf--p graf-after--p graf--trailing">A final note: <a href="http://stats.stackexchange.com/" data-href="http://stats.stackexchange.com/" class="markup--anchor markup--p-anchor" rel="noopener" target="_blank">Cross-Validated</a> has sometimes been helpful.</p></div></div></section><section name="09a3" class="section section--body section--last"><div class="section-divider"><hr class="section-divider"></div><div class="section-content"><div class="section-inner sectionLayout--insetColumn"><p name="81e8" id="81e8" class="graf graf--p graf--leading"><em class="markup--em markup--p-em">Next: </em><a href="https://medium.com/@username/post-url" data-href="https://medium.com/@username/post-url" class="markup--anchor markup--p-anchor" target="_blank"><em class="markup--em markup--p-em">Next post</em>
|
||||
</section>
|
||||
<footer><p>By <a href="https://medium.com/@username" class="p-author h-card">User Name</a> on <a href="https://medium.com/p/medium-short-url"><time class="dt-published" datetime="2017-04-21T17:11:55.799Z">April 21, 2017</time></a>.</p><p><a href="https://medium.com/@username/this-post-url" class="p-canonical">Canonical link</a></p><p>Exported from <a href="https://medium.com">Medium</a> on December 1, 2023.</p></footer></article></body></html>
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||
<p><object width="425" height="350"><param name="movie" value="http://www.youtube.com/v/XSrW-wAWZe4"></param><param name="wmode" value="transparent"></param><embed src="http://www.youtube.com/v/XSrW-wAWZe4" type="application/x-shockwave-flash" wmode="transparent" width="425" height="350"></embed></object></p>
|
||||
<blockquote><p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p></blockquote>
|
||||
<ul>
|
||||
<li>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</li>
|
||||
<li>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</li>
|
||||
</ul>
|
||||
<pre>
|
||||
<code>
|
||||
a = [1, 2, 3]
|
||||
b = [4, 5, 6]
|
||||
for i in zip(a, b):
|
||||
print i
|
||||
</code>
|
||||
</pre>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</p>
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
|
||||
<object width="425" height="350"><param name="movie" value="http://www.youtube.com/v/XSrW-wAWZe4"></param><param name="wmode" value="transparent"></param><embed src="http://www.youtube.com/v/XSrW-wAWZe4" type="application/x-shockwave-flash" wmode="transparent" width="425" height="350"></embed></object>
|
||||
|
||||
<blockquote>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</blockquote>
|
||||
<ul>
|
||||
<li>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</li>
|
||||
<li>Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.</li>
|
||||
</ul>
|
||||
|
||||
<pre>
|
||||
<code>
|
||||
a = [1, 2, 3]
|
||||
b = [4, 5, 6]
|
||||
for i in zip(a, b):
|
||||
print i
|
||||
</code>
|
||||
</pre>
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod
|
||||
tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam,
|
||||
quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse
|
||||
cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non
|
||||
proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +0,0 @@
|
|||
First article
|
||||
#############
|
||||
|
||||
:date: 2018-11-10
|
||||
:summary: Here's the `second <{filename}/second-article.rst>`_,
|
||||
`third <{filename}/third-article.rst>`_ and a
|
||||
`nonexistent article <{filename}/nonexistent.rst>`_.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
Second article
|
||||
##############
|
||||
|
||||
:date: 2018-11-10
|
||||
:summary: Here's the `first <{filename}/first-article.rst>`_,
|
||||
`third <{filename}/third-article.rst>`_ and a
|
||||
`nonexistent article <{filename}/nonexistent.rst>`_.
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
Third article
|
||||
#############
|
||||
|
||||
:date: 2018-11-10
|
||||
:summary: Here's the `first <{filename}/first-article.rst>`_,
|
||||
`second <{filename}/second-article.rst>`_ and a
|
||||
`nonexistent article <{filename}/nonexistent.rst>`_.
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
AUTHOR = "Alexis Métaireau"
|
||||
SITENAME = "Alexis' log"
|
||||
SITEURL = "http://blog.notmyidea.org"
|
||||
TIMEZONE = "UTC"
|
||||
|
||||
GITHUB_URL = "http://github.com/ametaireau/"
|
||||
DISQUS_SITENAME = "blog-notmyidea"
|
||||
PDF_GENERATOR = False
|
||||
REVERSE_CATEGORY_ORDER = True
|
||||
DEFAULT_PAGINATION = 2
|
||||
|
||||
FEED_RSS = "feeds/all.rss.xml"
|
||||
CATEGORY_FEED_RSS = "feeds/{slug}.rss.xml"
|
||||
|
||||
LINKS = (
|
||||
("Biologeek", "http://biologeek.org"),
|
||||
("Filyb", "http://filyb.info/"),
|
||||
("Libert-fr", "http://www.libert-fr.com"),
|
||||
("N1k0", "http://prendreuncafe.com/blog/"),
|
||||
("Tarek Ziadé", "http://ziade.org/blog"),
|
||||
("Zubin Mithra", "http://zubin71.wordpress.com/"),
|
||||
)
|
||||
|
||||
SOCIAL = (
|
||||
("twitter", "http://twitter.com/ametaireau"),
|
||||
("lastfm", "http://lastfm.com/user/akounet"),
|
||||
("github", "http://github.com/ametaireau"),
|
||||
)
|
||||
|
||||
# global metadata to all the contents
|
||||
DEFAULT_METADATA = {"yeah": "it is"}
|
||||
|
||||
# path-specific metadata
|
||||
EXTRA_PATH_METADATA = {
|
||||
"extra/robots.txt": {"path": "robots.txt"},
|
||||
}
|
||||
|
||||
# static paths will be copied without parsing their contents
|
||||
STATIC_PATHS = [
|
||||
"pictures",
|
||||
"extra/robots.txt",
|
||||
]
|
||||
|
||||
FORMATTED_FIELDS = ["summary", "custom_formatted_field"]
|
||||
|
||||
# foobar will not be used, because it's not in caps. All configuration keys
|
||||
# have to be in caps
|
||||
foobar = "barbaz"
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
NAME = "namespace plugin"
|
||||
|
||||
|
||||
def register():
|
||||
pass
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
from .submodule import noop # noqa: F401
|
||||
|
||||
|
||||
def register():
|
||||
pass
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
def noop():
|
||||
pass
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue