Начальная редакция

master
Антон Касимов 2018-10-15 01:43:11 +03:00
commit d524359250
29 changed files with 715 additions and 0 deletions

49
.hgignore Normal file
View File

@ -0,0 +1,49 @@
syntax: glob
.gitignore
node_modules
.c9revisions
.DS_Store
Thumbs.db
*.pyc
*.pyo
*.pyd
# C extensions
*.so
# Packages
*.egg
*.egg-info
dist
build
eggs
parts
bin
var
sdist
develop-eggs
.installed.cfg
lib
lib64
include
man
__pycache__
public
# Installer logs
pip-log.txt
# Unit test / coverage reports
.coverage
.tox
nosetests.xml
# Translations
*.mo
cms/webpack
# Ignore IDEA files
.idea

4
CHANGES.txt Normal file
View File

@ -0,0 +1,4 @@
0.0
---
- Initial version.

2
MANIFEST.in Normal file
View File

@ -0,0 +1,2 @@
include *.txt *.ini *.cfg *.rst
recursive-include cms *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2

12
README.txt Normal file
View File

@ -0,0 +1,12 @@
Radium Flat-file CMS
==========
Getting Started
---------------
- Install flatfilecms
pip install flatfilecms
- Use flatfilecms:main as a WSGI application

80
flatfilecms/__init__.py Normal file
View File

@ -0,0 +1,80 @@
from pyramid.config import Configurator
from pyramid.path import AssetResolver
from .resources import Root
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
from pathlib import Path
class PagesEventHandler(FileSystemEventHandler):
def __init__(self, root, root_path):
super(PagesEventHandler, self).__init__()
self.root = root
self.root_path = root_path
def find_dir(self, path):
folder = self.root
path = path.relative_to(self.root_path)
for name in path.parts[:-1]:
folder = folder[name]
return folder
def on_created(self, event):
super(PagesEventHandler, self).on_created(event)
path = Path(event.dest_path if hasattr(event, 'dest_path')
else event.src_path)
folder = self.find_dir(path)
if event.is_directory:
folder.create_dir(path)
else:
folder.create_file(path)
def on_deleted(self, event):
super(PagesEventHandler, self).on_deleted(event)
path = Path(event.src_path)
folder = self.find_dir(path)
name = path.stem
if path.suffix not in ['.md', '.yaml', '.j2', '.jinja2'] \
and path.name != 'index.html':
name = path.name
del folder[name]
def on_moved(self, event):
super(PagesEventHandler, self).on_moved(event)
self.on_deleted(event)
self.on_created(event)
def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
from pyramid.settings import asbool
watchdog = asbool(settings.get(
'watchdog', 'false'))
settings['watchdog'] = watchdog
settings.setdefault('pages_dir', 'pages')
settings.setdefault('data_dir', 'data')
pages = Root(settings['pages_dir'])
def factory(request):
return pages
config = Configurator(settings=settings,
root_factory=factory)
config.include('pyramid_jinja2')
config.include('.routes')
config.scan()
if watchdog:
path = AssetResolver().resolve(
settings['pages_dir']).abspath()
observer = Observer()
event_handler = PagesEventHandler(pages, path)
observer.schedule(
event_handler,
path,
recursive=True)
observer.start()
return config.make_wsgi_app()

57
flatfilecms/filters.py Normal file
View File

@ -0,0 +1,57 @@
from jinja2 import contextfilter, Markup
import markdown
from pathlib import Path
from markdown.extensions import Extension
from markdown.blockprocessors import BlockProcessor
class Jinja2Processor(BlockProcessor):
def test(self, parent, block):
return block.startswith('{%') or block.startswith('{{')
def run(self, parent, blocks):
block = blocks.pop(0)
sibling = self.lastChild(parent)
if sibling is not None:
if sibling.tail:
sibling.tail = sibling.tail + block
else:
sibling.tail = block
else:
if parent.text:
parent.text = parent.text + block
else:
parent.text = block
return True
class IgnoreExtension(Extension):
def extendMarkdown(self, md, md_globals):
md.parser.blockprocessors.add(
'jinja2',
Jinja2Processor(md.parser),
">hashheader")
@contextfilter
def markdown2html(context, text, render=True):
result = markdown.markdown(
text,
extensions=[IgnoreExtension(), 'markdown.extensions.extra'],
output_format='html5',
tab_length=2)
if render:
result = context.environment.from_string(result).render(context)
if context.eval_ctx.autoescape:
result = Markup(result)
return result
def merge_dict(a, b):
if isinstance(a, list):
return [merge_dict(i, b) for i in a]
return {**a, **b}
def fileglob(path, root):
return [p.relative_to(root) for p in Path(root).glob(path)]

75
flatfilecms/resources.py Normal file
View File

@ -0,0 +1,75 @@
from pyramid.path import AssetResolver
from pathlib import PurePath
def flat(d, path):
structure = []
for k, v in d.items():
if isinstance(v, dict):
structure.extend(flat(v, f"{path}/{k}"))
else:
if k == 'index':
structure.append(f"{path}/")
else:
structure.append(f"{path}/{k}")
return structure
class Folder(dict):
def __init__(self, name, parent, path):
self.__path__ = path
self.__name__ = name
self.__parent__ = parent
for entry in AssetResolver().resolve(path).listdir():
asset = f"{path}/{entry}"
if AssetResolver().resolve(asset).isdir():
self.create_dir(asset)
else:
self.create_file(asset)
def create_file(self, asset):
path = PurePath(asset)
if path.suffix == '.md':
self[path.stem] = Markdown(path.stem, self, asset)
elif path.suffix == '.yaml':
self[path.stem] = YAML(path.stem, self, asset)
elif path.suffix == '.j2' or path.suffix == '.jinja2':
self[path.stem] = Jinja2(path.stem, self, asset)
else:
name = path.name
# Если имя файла не index.html,
# то отдавать по имени файла
if name == 'index.html':
name = 'index'
self[name] = Document(name, self, asset)
def create_dir(self, asset):
path = PurePath(asset)
self[path.name] = Folder(path.name, self, asset)
def structure(self, base_dir=''):
return flat(self, base_dir)
class Root(Folder):
def __init__(self, path):
super(Root, self).__init__('', None, path)
class Document(object):
def __init__(self, name, parent, path):
self.__name__ = name
self.__parent__ = parent
self.__path__ = path
class Markdown(Document):
pass
class YAML(Document):
pass
class Jinja2(Document):
pass

7
flatfilecms/routes.py Normal file
View File

@ -0,0 +1,7 @@
def includeme(config):
settings = config.get_settings()
if 'static' in settings:
config.add_static_view(
'static',
settings['static'],
cache_max_age=3600)

View File

@ -0,0 +1,5 @@
{% extends "layout.jinja2" %}
{% set title="404: страница не найдена!" %}
{% set description="Попробуйте найти нужную вам страницу в меню сайта." %}
{% set image="https://source.unsplash.com/Q1p7bh3SHj8" %}

View File

View File

@ -0,0 +1,18 @@
<!doctype html>
<html ⚡>
<head>
<meta charset="utf-8">
<title>{{title}}</title>
<script async src="https://cdn.ampproject.org/v0.js"></script>
<script async custom-element="amp-analytics" src="https://cdn.ampproject.org/v0/amp-analytics-0.1.js"></script>
<link rel="canonical" href="{{canonical | default(request.url)}}">
<meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">
<style amp-boilerplate>body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}</style><noscript><style amp-boilerplate>body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}</style></noscript>
</head>
<body>
{% block body %}
{{content}}
{% endblock %}
{% include 'amp-counter.jinja2' %}
</body>
</html>

View File

@ -0,0 +1,29 @@
{%- from 'macros.jinja2' import b -%}
<!DOCTYPE html>
<html lang="{{request.locale_name}}">
<head>
{% block head -%}
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{description}}">
<meta name="generator" content="flatfilecms">
<link rel="icon" type="image/png" href="/static/img/favicon.png">
<title>{{title}}</title>
{% block links %}
{%- endblock %}
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#333333">
{%- if json_ld %}
<script type="application/ld+json">
{{ json_ld | merge_dict({"@context": "http://schema.org"}) | tojson }}
</script>
{%- endif -%}
{% endblock %}
</head>
<body>
{% include 'counter.jinja2' %}
{% block body %}
{% endblock %}
</body>
</html>

View File

@ -0,0 +1,6 @@
<div style="background: url({{jumbotron_image}}) no-repeat center center; background-size: cover">
<div class="jumbotron container {{jumbotron_class|default('text-light bg-darken')}}">
<h2>{{jumbotron_header}}</h2>
{{jumbotron_content}}
</div>
</div>

View File

View File

View File

@ -0,0 +1,8 @@
<nav class="nav justify-content-left container py-3 footer-menu">
{%- for item in menu recursive %}
{% if item.href -%}
<a href="{{item.href|e}}" class="nav-link text-light py-0">{{item.title}}</a>
{%- endif -%}
{{ loop(item.children) }}
{%- endfor %}
</nav>

View File

@ -0,0 +1,60 @@
<div{% if image %} style="background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7)), url({{image}}) no-repeat center center; background-size:cover"{% endif %}>
<nav class="navbar navbar-expand-sm navbar-dark container">
<a href="/" class="navbar-brand">
{% include 'logo.jinja2' %}
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div id="navbarSupportedContent" class="collapse navbar-collapse">
<ul class="navbar-nav">
{%- for item in menu %}
<li class="nav-item dropdown">
{%- if item.children %}
<a role="button" class="nav-link dropdown-toggle cursor-default" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{{item.title}}</a>
<ul class="dropdown-menu">
{%- if item.href %}
<li>
<a href="{{item.href|e}}" class="dropdown-item">{{item.title}}</a>
</li>
<li>
<div class="dropdown-divider"></div>
</li>
{%- endif -%}
{%- for subitem in item.children recursive -%}
{%- if subitem.children %}
<li class="dropright">
<button class="dropdown-item dropdown-toggle" type="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">{{subitem.title}}</button>
<ul class="dropdown-menu">
{%- if subitem.href %}
<li>
<a href="{{subitem.href|e}}" class="dropdown-item">{{subitem.title}}</a>
</li>
<li>
<div class="dropdown-divider"></div>
</li>
{%- endif -%}
{{ loop(subitem.children) }}
</ul>
{%- else -%}
<li>
<a href="{{subitem.href|e}}" class="dropdown-item">{{subitem.title}}</a>
{%- endif -%}
</li>
{%- endfor %}
</ul>
{%- else %}
<a href="{{item.href|e}}" class="nav-link">{{item.title}}</a>
{%- endif %}
</li>
{%- endfor %}
</ul>
</div>
</nav>
<div class="jumbotron container text-white bg-transparent">
<h1 class="display-4">{{title}}</h1>
{%- if description %}
<p class="lead">{{description}}</p>
{% endif %}
</div>
</div>

View File

@ -0,0 +1,5 @@
{% extends "layout.jinja2" %}
{% block content %}
{{ contents|safe }}
{% endblock content %}

View File

@ -0,0 +1,26 @@
{% extends "base.jinja2" %}
{% block links %}
{% webpack 'layout', '.js' -%}
<script src="{{ ASSET.url }}" async defer></script>
{% endwebpack %}
{% webpack 'layout', '.css' -%}
<link rel="stylesheet" type="text/css" href="{{ ASSET.url }}">
{% endwebpack %}
{% endblock %}
{% block body %}
<header>
{% include "header.jinja2" %}
</header>
<main class="py-4">
<article class="container">
{% block content %}
{% if content %}
{{content|markdown}}
{% endif %}
{% endblock %}
</article>
</main>
<footer class="page-footer">
{% include "footer.jinja2" %}
</footer>
{% endblock %}

View File

View File

@ -0,0 +1,10 @@
{% macro b(class=None, tag="div", id=None) -%}
<{{ tag }}
{%- if class is not none %} class="{{class}}" {%- endif -%}
{%- if id is not none %} id="{{id}}" {%- endif -%}
>
{%- if caller %}
{{ caller() }}
{% endif -%}
</{{ tag }}>
{%- endmacro %}

View File

@ -0,0 +1,42 @@
{% extends "base.jinja2" %}
{% block links %}
{% webpack 'onepage', '.js' -%}
<script src="{{ ASSET.url }}" async defer></script>
{% endwebpack %}
{% webpack 'onepage', '.css' -%}
<link rel="stylesheet" type="text/css" href="{{ ASSET.url }}">
{% endwebpack %}
{% endblock %}
{% block body %}
<header>
<nav id="nav-page" class="navbar navbar-expand-sm navbar-dark fixed-top">
<a href="/" class="navbar-brand">
{% include 'logo.jinja2' %}
</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbarSupportedContent">
<ul class="navbar-nav">
{%- for item in nav %}
<li class="nav-item">
<a href="{{item.link}}" class="nav-link">{{item.name}}</a>
</li>
{% endfor %}
</ul>
</div>
</nav>
</header>
<main>
<article>
{% block content %}
{% if content %}
{{content|markdown}}
{% endif %}
{% endblock %}
</article>
</main>
<footer class="page-footer">
{% include "footer.jinja2" %}
</footer>
{% endblock %}

View File

@ -0,0 +1 @@
{% extends "layout.jinja2" %}

View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{%- for item in request.root.structure() %}{% if item != '/sitemap.xml' and item not in data.ignore %}
<url>
<loc>{{data.base_url}}{{item}}</loc>
</url>
{%- endif %}{%- endfor %}
</urlset>

View File

@ -0,0 +1,20 @@
{% extends "layout.jinja2" %}
{% block content %}
{% filter markdown %}
{% if content %}
{{content|safe}}
{% endif %}
{% for name in ['why', 'what', 'who', 'call'] %}
{% if name in data %}
{{("{% call b(tag='section', id='"+name+"') %}")|safe}}
{{data[name]|safe}}
{{"{% endcall %}"|safe}}
{% endif %}
{% endfor %}
{% if 'abbr' in data %}
{{data['abbr']|safe}}
{% endif %}
{% endfilter %}
{% endblock %}

View File

View File

@ -0,0 +1,7 @@
from pyramid.view import notfound_view_config
@notfound_view_config(renderer='../templates/404.jinja2')
def notfound_view(request):
request.response.status = 404
return {}

120
flatfilecms/views/pages.py Normal file
View File

@ -0,0 +1,120 @@
from pyramid.view import (
view_config,
render_view_to_response,
)
from pyramid.renderers import render_to_response
from pyramid.httpexceptions import (HTTPFound, HTTPNotFound)
from pyramid.response import FileResponse
from pyramid.path import AssetResolver
from pathlib import PurePath
from ..resources import (Folder, Document, Markdown, YAML, Jinja2)
import yaml
import frontmatter
class Loader(yaml.Loader):
def __init__(self, stream):
super(Loader, self).__init__(stream)
Loader.add_constructor('!include', Loader.include)
def include(self, node):
if isinstance(node, yaml.ScalarNode):
return self.extractFile(self.construct_scalar(node))
elif isinstance(node, yaml.SequenceNode):
return [self.extractFile(filename)
for filename in self.construct_sequence(node)]
elif isinstance(node, yaml.MappingNode):
result = {}
for k, v in self.construct_mapping(node).items():
result[k] = self.extractFile(v)
return result
else:
raise yaml.constructor.ConstructorError(
"Error:: unrecognised node type in !include statement")
def extractFile(self, filename):
path = PurePath(self.data_dir) / filename
f = AssetResolver().resolve(str(path)).stream()
if f:
if path.suffix in ['.yaml', '.yml', '.json']:
return yaml.load(f, Loader)
return f.read().decode()
def LoaderFactory(data_dir):
cl = Loader
cl.data_dir = data_dir
return cl
class CustomYAMLHandler(frontmatter.YAMLHandler):
def __init__(self, data_dir):
self.loader = LoaderFactory(data_dir)
def load(self, fm, **kwargs):
return yaml.load(fm, self.loader)
class PagesView:
def __init__(self, context, request):
self.context = context
self.request = request
@view_config(context=Folder)
def folder(self):
if 'index' not in self.context:
raise HTTPNotFound
return render_view_to_response(self.context['index'], self.request)
@view_config(context=Document)
def document(self):
return FileResponse(
AssetResolver().resolve(self.context.__path__).abspath(),
request=self.request)
def process_yaml(self):
if 'redirect' in self.post:
return HTTPFound(location=self.post.redirect)
if 'menu' not in self.post:
self.post['menu'] = yaml.load(
AssetResolver().resolve(
str(PurePath(self.request.registry.settings['data_dir']) /
'menu/default.yaml')).stream(),
LoaderFactory(self.request.registry.settings['data_dir']))
response = render_to_response(
'{0}.jinja2'.format(
self.post.get('template', 'default')),
self.post,
request=self.request
)
if 'content_type' in self.post:
response.content_type = self.post['content_type']
return response
@view_config(context=YAML)
def yaml(self):
self.post = yaml.load(
AssetResolver().resolve(
self.context.__path__).stream(),
LoaderFactory(self.request.registry.settings['data_dir']))
return self.process_yaml()
@view_config(context=Markdown)
def markdown(self):
self.post = frontmatter.load(
AssetResolver().resolve(
self.context.__path__).stream(),
handler=CustomYAMLHandler(self.request.registry.settings['data_dir']),
).to_dict()
return self.process_yaml()
@view_config(context=Jinja2)
def jinja2(self):
return render_to_response(
self.context.path,
{},
request=self.request
)

64
setup.py Normal file
View File

@ -0,0 +1,64 @@
import os
from setuptools import setup, find_packages
here = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(here, 'README.txt')) as f:
README = f.read()
with open(os.path.join(here, 'CHANGES.txt')) as f:
CHANGES = f.read()
requires = [
'plaster_pastedeploy',
'pyramid >= 1.9a',
'pyramid_debugtoolbar',
'pyramid_jinja2',
'pyramid_retry',
'pyramid_tm',
'transaction',
'waitress',
'Markdown',
'PyYAML',
'python-frontmatter',
'watchdog < 0.9.0',
'pyramid-htmlmin',
'pyramid-webpack',
]
tests_require = [
'WebTest >= 1.3.1', # py3 compat
'pytest',
'pytest-cov',
]
setup(
name='flatfilecms',
version='0.0',
description='Radium CMS',
long_description=README + '\n\n' + CHANGES,
classifiers=[
'Programming Language :: Python',
'Framework :: Pyramid',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Internet :: WWW/HTTP :: WSGI :: Application',
],
author='Anton Kasimov',
author_email='kasimov@radium-it.ru',
url='http://www.radium.group',
keywords='web pyramid pylons flat-file CMS',
packages=find_packages(),
include_package_data=True,
zip_safe=False,
extras_require={
'testing': tests_require,
},
install_requires=requires,
entry_points={
'paste.app_factory': [
'main = flatfilecms:main',
],
'console_scripts': [
'generate_static_site = flatfilecms.scripts.generate:main',
],
},
)