Metadata-Version: 2.4
Name: flask-wiki
Version: 4.0.0
Summary: Simple file-based wiki for Flask
Author: RERO+
Author-email: RERO+ <software@rero.ch>
License-Expression: BSD-3-Clause
License-File: LICENSE
Requires-Dist: bootstrap-flask
Requires-Dist: flask
Requires-Dist: flask-wtf
Requires-Dist: babel>=2.16.0
Requires-Dist: flask-babel>=3.0.0
Requires-Dist: wtforms
Requires-Dist: werkzeug>=0.15
Requires-Dist: markdown
Requires-Dist: beautifulsoup4
Requires-Dist: click
Requires-Dist: whoosh-reloaded>=2.7.5
Requires-Dist: poethepoet
Requires-Python: >=3.14, <3.15
Description-Content-Type: text/markdown

<!--
SPDX-FileCopyrightText: Fondation RERO+
SPDX-License-Identifier: BSD-3-Clause
-->

# Flask-Wiki

A lightweight, file-based wiki system built as a Flask extension. Create, edit, search, and manage wiki pages stored as Markdown files on the filesystem -- no database required.

## Features

- Markdown pages with metadata (title, tags)
- Full-text search powered by Whoosh
- File/image uploads
- WikiLinks (`[[Page Name]]` syntax)
- Multilingual support
- Markdown editor with a server-rendered preview tab
- No CDN: every front-end asset is served by the application
- Customizable templates and permissions

## Installation

```bash
pip install flask-wiki
```

## Quick start

```python
from flask import Flask
from flask_wiki import Wiki

app = Flask(__name__)
app.config["SECRET_KEY"] = "your-secret-key"
Wiki(app)
```

Or using the application factory pattern:

```python
from flask import Flask
from flask_wiki import Wiki

wiki = Wiki()


def create_app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = "your-secret-key"
    wiki.init_app(app)
    return app
```

The wiki will be available at `/help` by default (configurable via `WIKI_URL_PREFIX`).

### Initialize the search index

Before using search, initialize the Whoosh index:

```bash
flask flask_wiki init-index
flask flask_wiki index
```

## How it works

Wiki pages are plain Markdown files stored in a content directory (`./data` by default). The URL structure mirrors the filesystem, and every page belongs to a language: `/help/guides/setup` maps to `data/guides/setup_en.md` for an English reader. See [Internationalization](#internationalization).

Each page file has an optional metadata header followed by the Markdown body:

```markdown
title: My Page Title
tags: setup, guide

# Content starts here

Regular markdown content...
```

The wiki registers a Flask Blueprint with routes for viewing, editing, searching, and managing pages. Uploaded files (images) are stored in a subfolder and served via middleware.

## Permissions

Flask-Wiki uses a callable-based permission system. The host application provides functions that return `True` or `False` to control access. By default, everything is open (all lambdas return `True`).

There are four permission settings:

| Setting | Purpose |
|---------|---------|
| `WIKI_READ_VIEW_PERMISSION` | Controls access to read routes (view pages, search). Returns 403 if `False`. |
| `WIKI_EDIT_VIEW_PERMISSION` | Controls access to edit routes (edit, delete, upload). Returns 403 if `False`. |
| `WIKI_READ_UI_PERMISSION` | Controls visibility of read-related UI elements in templates. |
| `WIKI_EDIT_UI_PERMISSION` | Controls visibility of edit buttons/links in templates. |

Each permission is a callable (no arguments) that is evaluated per-request. This lets you integrate with any authentication system -- Flask-Login, session-based auth, API tokens, etc.

### Example: integrating with Flask-Login

```python
from flask_login import current_user

app.config["WIKI_READ_VIEW_PERMISSION"] = lambda: current_user.is_authenticated
app.config["WIKI_EDIT_VIEW_PERMISSION"] = lambda: current_user.is_authenticated and current_user.has_role("editor")
app.config["WIKI_EDIT_UI_PERMISSION"] = app.config["WIKI_EDIT_VIEW_PERMISSION"]
```

The `VIEW` permissions are enforced server-side via route decorators. The `UI` permissions only toggle visibility of buttons and links in the templates -- they do not enforce access control on their own. Typically you'll set the UI permissions to match the view permissions, but you can separate them if needed (e.g., show a "log in to edit" button to anonymous users).

## Configuration

### Content & storage

| Key | Default | Description |
|-----|---------|-------------|
| `WIKI_HOME` | `'home'` | Default page for `/` |
| `WIKI_URL_PREFIX` | `'/help'` | URL prefix for the wiki blueprint |
| `WIKI_CONTENT_DIR` | `'./data'` | Directory for Markdown files |
| `WIKI_UPLOAD_FOLDER` | `'./data/files'` | Directory for uploaded images |
| `WIKI_ALLOWED_EXTENSIONS` | `{'png','jpg','jpeg','gif','svg'}` | Allowed upload types |
| `WIKI_INDEX_DIR` | `'./index'` | Whoosh search index directory |

#### A prefix with a variable part

`WIKI_URL_PREFIX` may carry variable parts, which is how an application keeps a
reader inside a section of its own -- a tenant, an organisation, a language:

```python
app.config["WIKI_URL_PREFIX"] = "/<org_code>/help"
```

The wiki serves `/unifr/help/setup` without ever knowing what `org_code` means:
its views never see the value, and every URL it builds carries it back. That
holds for the links of its templates, for the wikilinks of a page body, and
therefore for the whole navigation -- a reader who enters through one prefix
stays there.

Any URL rule syntax will do, a converter of your own included, as long as the
name does not collide with an argument of the wiki views -- `url` and
`filename`: `"/<org:org_code>/help"`.

Building such a URL from outside the wiki, from a footer for instance, means
passing the value: `url_for('wiki.index', org_code='unifr')`.

The uploaded files hang from the static part of the prefix, `/help/files/` in
the example above, whatever the prefix a reader came through. A WSGI mount
point carries no variable, and neither does the URL of an image written in a
page.

### Templates

All templates can be overridden by setting these config values to your own template paths:

| Key | Default |
|-----|---------|
| `WIKI_BASE_TEMPLATE` | `'wiki/base.html'` |
| `WIKI_PAGE_TEMPLATE` | `'wiki/page.html'` |
| `WIKI_EDITOR_TEMPLATE` | `'wiki/editor.html'` |
| `WIKI_SEARCH_TEMPLATE` | `'wiki/search.html'` |
| `WIKI_FILES_TEMPLATE` | `'wiki/files.html'` |
| `WIKI_NOT_FOUND_TEMPLATE` | `'wiki/404.html'` |
| `WIKI_FORBIDDEN_TEMPLATE` | `'wiki/403.html'` |
| `WIKI_ICON_TEMPLATE` | `'wiki/icons/bootstrap.html'` |
| `WIKI_TOAST_TEMPLATE` | `'wiki/toast.html'` |

### Front-end assets

The wiki needs no build step, no CDN and no vendored third-party asset. Its
whole front-end comes from `bootstrap-flask`:

| Asset | Origin |
|-------|--------|
| Bootstrap 4, jQuery, Popper | shipped by `bootstrap-flask` |
| Bootstrap Icons (SVG sprite) | shipped by `bootstrap-flask` |

Set `BOOTSTRAP_SERVE_LOCAL = True` so `bootstrap-flask` serves its own assets
instead of a CDN, which is what makes the wiki work without internet access:

```python
app.config["BOOTSTRAP_SERVE_LOCAL"] = True
```

#### Page layout

Every page — article, editor, file listing, search results, error — is laid out
by the same three class names, which an application overriding one of the page
templates has to keep:

| Class | Role |
|-------|------|
| `wiki-page` | the grid: one column on a narrow screen, article plus outline from 768px up |
| `wiki-toc` | the table of contents, first in the source, placed on the right on a wide screen |
| `wiki-content` | the main column: article, editor form or listing, with its header |

The TOC precedes the article in the source so a narrow screen shows it
first, and the grid moves it to the right-hand column on a wide one. It is
pinned with `position: sticky` and scrolls on its own once it outgrows the
viewport. A page without headings renders no `wiki-toc` at all, and the grid
falls back to a single centered column.

#### Feedback

Feedback shares one channel: the messages flashed by the server and the ones the
browser raises on its own are all toasts, stacked in a `wiki-toasts` container
fixed to the top right. Their markup comes from the `toast` macro of
`WIKI_TOAST_TEMPLATE`, and the toasts the wiki raises by itself live in
`wiki/toasts.html`.

An application overriding `WIKI_BASE_TEMPLATE` renders its own stack. It
includes `wiki/toasts.html` in it, so it never has to know the ids `wiki.js`
reveals nor repeat their messages, and it renders the flashed messages itself if
nothing else in the application already does:

```jinja
{% raw %}<div class="wiki-toasts">
  {% for category, message in get_flashed_messages(with_categories=True) %}
  {{ toast(message, category, autoshow=True) }}
  {% endfor %}
  {%- include "wiki/toasts.html" %}
</div>{% endraw %}
```

To render those toasts as the rest of the application does, point
`WIKI_TOAST_TEMPLATE` at a template of its own supplying a macro with the same
signature — `toast(message, category='message', id=None, autoshow=False)`, where
`id` marks a toast kept hidden until a script reveals it, and `autoshow` one
`wiki.js` reveals as soon as the page is ready:

```python
app.config["WIKI_TOAST_TEMPLATE"] = "myapp/macros/toast.html"
```

#### Icons

Templates never name a glyph directly. They ask for an *intent* — `search`,
`copy`, `edit`, `upload`, `delete`, `language`, `save` — and `WIKI_ICON_TEMPLATE`
supplies the markup for it:

| Value | Markup | Assets needed |
|-------|--------|---------------|
| `'wiki/icons/bootstrap.html'` (default) | inline SVG using the Bootstrap Icons sprite | none, `bootstrap-flask` ships it |
| `'wiki/icons/fontawesome.html'` | `<i class="fa-solid fa-...">` | Font Awesome 7, supplied by your application |

The Font Awesome variant emits class names only; it bundles nothing. Use it in
an application that already ships Font Awesome — through a webpack bundle, for
instance — and the wiki icons match the rest of that application:

```python
app.config["WIKI_ICON_TEMPLATE"] = "wiki/icons/fontawesome.html"
```

Any template exposing an `icon(name)` macro works, so an application needing
different styles or a third icon set can point the key at its own file.

Pages are edited in a plain `<textarea>`; the *Preview* tab posts the body to
`wiki.preview` and renders it with the same Markdown pipeline as a saved page,
so WikiLinks, captions and syntax highlighting show up exactly as they will.

### Internationalization

| Key | Default | Description |
|-----|---------|-------------|
| `WIKI_CURRENT_LANGUAGE` | `lambda: 'en'` | Callable returning the current language code |
| `WIKI_LANGUAGES` | `{'en': 'English', 'fr': 'French', 'de': 'German', 'it': 'Italian'}` | Available languages |
| `WIKI_FALLBACK_LANGUAGES` | every language of `WIKI_LANGUAGES`, in order | Languages tried, in order, when a page has no variant in the current language |

Every page belongs to a language: its filename carries a language code (`page_fr.md`, `page_de.md`), and `/help/page` serves the variant matching `WIKI_CURRENT_LANGUAGE`. Pages created or edited through the wiki are always saved with a language code.

When a page has no variant in the current language, the wiki walks `WIKI_FALLBACK_LANGUAGES` in order and serves the first translation it finds, with a banner telling the reader which language the page is displayed in. A page is only a 404 when it exists in no language at all. Set `WIKI_FALLBACK_LANGUAGES = []` to disable the cascade.

Page listings (index, tags) and search results follow the same cascade: one entry per page, in the current language when it exists.

Files without a language code (`page.md`) are still served, as a last resort, for wikis created before language codes became mandatory. They are read-only: editing one writes the variant of the current language and leaves the original untouched. To migrate such a wiki, rename its files to `page_<language>.md` and re-run `flask flask_wiki index`.

### Markdown

| Key | Default | Description |
|-----|---------|-------------|
| `WIKI_MARKDOWN_EXTENSIONS` | `{'codehilite', 'fenced_code'}` | Additional Python-Markdown extensions |

The extensions `toc`, `meta`, `tables`, and a built-in Bootstrap extension are always loaded.

An image that carries a title and stands alone in its paragraph is rendered as a
captioned `<figure>`: the title becomes the caption, and the alt text keeps describing
the image for those who cannot see it. An image without a title, or one sitting inside a
sentence, is rendered as a plain `<img>`.

## Development

### Requirements

- Python >=3.14,<3.15
- [uv](https://docs.astral.sh/uv/)

### Setup

```bash
git clone <repo-url>
cd flask-wiki
uv sync --frozen
```

### Run the example app

```bash
cd examples
uv run flask flask_wiki init-index
uv run flask flask_wiki index
uv run flask run --debug
# Visit http://localhost:5000/help
```

### Run tests

```bash
uv run poe run_tests
```

## License

BSD 3-Clause. See [LICENSE](LICENSE) for details.
