|
2 | 2 |
|
3 | 3 | Thank you for considering to help this project. |
4 | 4 |
|
5 | | -We welcome all support, whether on bug reports, code, design, reviews, tests, documentation, and more. Check out the [project roadmap](../ROADMAP.md) for high-level ideas that align with the project’s goals. |
| 5 | +We welcome all support, whether on bug reports, code, design, reviews, tests, documentation, and more. Check out the [project roadmap](../ROADMAP.md) for high-level ideas that align with the project's goals. |
6 | 6 |
|
7 | 7 | Please note that this project is released with a [Contributor Code of Conduct](docs/CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms. |
8 | 8 |
|
| 9 | +## Project architecture |
| 10 | + |
| 11 | +Understanding the high-level architecture will help you navigate the codebase and find where to make changes. |
| 12 | + |
| 13 | +### Module organization |
| 14 | + |
| 15 | +```ba |
| 16 | +draftjs_exporter/ |
| 17 | + html.py # Entry point. HTML class orchestrates the full rendering pipeline. |
| 18 | + dom.py # Virtual DOM abstraction. Static facade over interchangeable engines. |
| 19 | + command.py # Command model – operations derived from Draft.js ranges. |
| 20 | + entity_state.py # Entity (link, image, embed) rendering state machine. |
| 21 | + style_state.py # Inline style (bold, italic) nesting state machine. |
| 22 | + wrapper_state.py # Block wrapper nesting (e.g. <ul>/<ol> around <li>). |
| 23 | + options.py # Configuration normalization – converts user config to internal format. |
| 24 | + composite_decorators.py # Regex-based text decorators (line breaks, mentions, linkify). |
| 25 | + constants.py # BLOCK_TYPES, ENTITY_TYPES, INLINE_STYLES enums. |
| 26 | + defaults.py # Default BLOCK_MAP and STYLE_MAP for HTML. |
| 27 | + types.py # Type aliases and TypedDicts for the public API. |
| 28 | + error.py # Exception base classes. |
| 29 | + engines/ |
| 30 | + base.py # DOMEngine abstract base class. |
| 31 | + string.py # String-concatenation engine (fast, no dependencies, default). |
| 32 | + string_compat.py # Backward-compatible variant of the string engine. |
| 33 | + html5lib.py # BeautifulSoup / html5lib engine. |
| 34 | + lxml.py # lxml engine. |
| 35 | + markdown.py # Non-escaping string engine for Markdown output. |
| 36 | + markdown/ # Markdown-specific components, config builder, and helpers. |
| 37 | + utils/ |
| 38 | + module_loading.py # import_string() for dotted-path class resolution. |
| 39 | +``` |
| 40 | + |
| 41 | +Tests mirror this structure under `tests/`, with sub-packages for `engines/`, `markdown/`, and `utils/`. |
| 42 | + |
| 43 | +### Rendering pipeline |
| 44 | + |
| 45 | +The core flow lives in `HTML.render()` and proceeds as follows: |
| 46 | + |
| 47 | +1. **Engine setup** – `DOM.engine()` resolves the engine string to a class (via `import_string`), caches it, and sets it in a thread-safe `ContextVar`. |
| 48 | +2. **Option normalisation** – `Options.map_blocks()`, `Options.map_styles()`, `Options.map_entities()` convert the user-friendly config maps into flat `OptionsMap` dicts of type → normalized `Options` objects. |
| 49 | +3. **Block iteration** – `HTML.render()` creates a `WrapperState` instance and an empty document fragment, then iterates over each block. |
| 50 | +4. **Per-block rendering** – For each block, `render_block()` extracts text, inline styles, entity ranges, and composite decorators. It builds a sorted list of `Command` objects from the Draft.js ranges, groups consecutive commands by character offset, and processes each group through: |
| 51 | + - **EntityState** – manages an entity stack; on `start_entity`/`stop_entity` pairs it wraps children in entity components. |
| 52 | + - **StyleState** – tracks active inline styles; `render_styles()` wraps text in nested inline elements from innermost to outermost. |
| 53 | + - **Composite decorators** – regex-based text transformations (e.g. `\n` → `<br>`). |
| 54 | +5. **Wrapper resolution** – `wrapper_state.element_for()` resolves each block to a DOM element, managing nesting of wrapper elements (e.g. `<ul>`/`<ol>` for list items) based on block depth. |
| 55 | +6. **Final rendering** – All block elements are appended to the document fragment, then `DOM.render()` serialises the virtual DOM tree to the output string (HTML or Markdown). |
| 56 | + |
| 57 | +### Engine system |
| 58 | + |
| 59 | +The exporter uses a **Strategy pattern** for output generation. All engines implement the `DOMEngine` interface (five static methods: `create_tag`, `parse_html`, `append_child`, `render`, `render_debug`). The `DOM` class delegates to the active engine, selected at runtime via dotted-path strings stored as class constants (`DOM.STRING`, `DOM.HTML5LIB`, `DOM.LXML`, `DOM.MARKDOWN`, `DOM.STRING_COMPAT`). Engine selection is thread-safe through a `ContextVar`. |
| 60 | + |
| 61 | +### Key design patterns |
| 62 | + |
| 63 | +- **Strategy** – interchangeable engines selected at runtime. |
| 64 | +- **Facade** – `DOM` class hides engine-specific details. |
| 65 | +- **State machine** – `EntityState` tracks entity open/close via a stack. |
| 66 | +- **Command pattern** – operations on text are modelled as `Command` objects, sorted, grouped, and applied in order. |
| 67 | +- **Pipeline** – text passes through decorators → inline styles → entity wrapping → wrapper nesting, each stage wrapping the previous. |
| 68 | +- **Null object** – `WrapperStack.head()` returns a default `Wrapper(-1)` when the stack is empty. |
| 69 | +- **Slots for performance** – core classes use `__slots__` to reduce memory overhead. |
| 70 | + |
9 | 71 | ## Development |
10 | 72 |
|
11 | 73 | ### Installation |
@@ -39,13 +101,85 @@ just init |
39 | 101 | - `just build`: Builds package for publication. |
40 | 102 | - `just publish`: Publishes a new version to PyPI. |
41 | 103 |
|
| 104 | +### Dependencies |
| 105 | + |
| 106 | +This project uses multiple package managers and an automated dependency bot. |
| 107 | + |
| 108 | +**Python** – managed with [uv](https://github.com/astral-sh/uv). Runtime dependencies are optional extras only (`lxml`, `html5lib`). |
| 109 | + |
| 110 | +**JavaScript (tooling only)** – managed with `npm`. Only `prettier` is used, for formatting non-Python files. Locked in `package-lock.json`. Install with `npm install`. |
| 111 | + |
| 112 | +**Dependency updates** – handled by [Renovate](https://docs.renovatebot.com/): |
| 113 | + |
| 114 | +- Runs on a bi-weekly schedule (3rd and 22nd of each month). |
| 115 | +- PRs are labelled `dependencies`. |
| 116 | +- A 14-day minimum release age ensures stability before updates are proposed. |
| 117 | +- `uv.lock` is refreshed weekly (Mondays) to catch transitive dependency updates. |
| 118 | +- GitHub Actions, npm, and most Python dependencies are auto-merged. |
| 119 | +- `lxml`, `beautifulsoup4`, and `html5lib` are excluded from automated updates – their version bounds are intentionally conservative and must be updated manually after verifying there are no output changes. |
| 120 | + |
| 121 | +To manually update a dependency, edit the version in `pyproject.toml` or `package.json`, then run `uv sync --dev` or `npm install` to update the lockfile. Run `just test-compatibility` to verify the project works with the lower-bound dependency versions declared for optional extras. |
| 122 | + |
42 | 123 | ### Debugging |
43 | 124 |
|
44 | 125 | - Always run the tests. To auto-run with watch, use `npm install -g nodemon`, then `just test-watch`. |
45 | 126 | - Use a debugger. `uv pip install ipdb`, then `import ipdb; ipdb.set_trace()`. |
46 | 127 | - You can use `example.py` as a basic CLI to try out the exporter with arbitrary ContentState JSON: `echo '{"json": "contents"}' | ./example.py -`. |
| 128 | +- Inspect the DOM tree at any stage using `DOM.render_debug()` to see the virtual DOM structure before serialisation. |
| 129 | +- Run individual test files with `uv run pytest tests/test_dom.py`, or filter with `-k`: `uv run pytest tests/test_dom.py -k "test_create_element"`. |
| 130 | +- Use `just dev` to restart the example automatically whenever source files change. |
47 | 131 |
|
48 | | -### Releases |
| 132 | +## Coding style & conventions |
| 133 | + |
| 134 | +We follow [PEP 8](https://peps.python.org/pep-0008/) for Python code style, enforced automatically by `ruff`: |
| 135 | + |
| 136 | +- **Python**: formatted with `ruff format`, linted with `ruff check`. Configuration in `pyproject.toml`. |
| 137 | +- **Other files**: formatted with `prettier` (see `prettier.config.js`). |
| 138 | +- **Indentation**: 4 spaces, no tabs. |
| 139 | +- **Type annotations**: required on all production code, checked by `mypy` with strict settings and by `ty` (experimental). |
| 140 | +- **Naming**: `snake_case` for functions, methods, and variables; `PascalCase` for classes; `UPPER_CASE` for constants. Test modules follow `test_*.py`, test functions `test_*`, test classes `Test*`. |
| 141 | +- **Performance**: core classes should use `__slots__` to reduce memory overhead. |
| 142 | +- **Imports**: organised automatically by `ruff` (isort rules in `pyproject.toml`). |
| 143 | +- **Error handling**: use specific exception types; avoid bare `except:` clauses (BLE rules). |
| 144 | + |
| 145 | +## Testing |
| 146 | + |
| 147 | +We aim for 100% test coverage on all changes. Tests are run with `pytest` and configured in `pyproject.toml`. |
| 148 | + |
| 149 | +### Test organization |
| 150 | + |
| 151 | +Tests mirror the source layout: |
| 152 | + |
| 153 | +- `tests/test_<module>.py` – unit tests for each module under `draftjs_exporter/`. |
| 154 | +- `tests/engines/test_engines_*.py` – engine-specific unit tests. |
| 155 | +- `tests/markdown/test_*.py` – markdown-specific unit tests (auto-switch to the MARKDOWN engine via `conftest.py`). |
| 156 | +- `tests/utils/test_*.py` – utility tests. |
| 157 | + |
| 158 | +### Types of tests |
| 159 | + |
| 160 | +The project has a layered test suite to increase the opportunities to catch bugs. |
| 161 | + |
| 162 | +- **Unit tests** – individual classes and functions in isolation (most test files). Use `unittest.TestCase` style with `setUp`/`tearDown`. |
| 163 | +- **Integration tests** – `test_output.py` exercises the full pipeline end-to-end with complex content states (49 KB, 1284 lines). |
| 164 | +- **Data-driven / snapshot tests** – `test_exports.py` reads `test_exports.json` at module load time via a custom metaclass (`ExportsTestMeta`) and dynamically generates test methods for every engine × test case combination. Add a new test case to `test_exports.json` when you want to verify output across all engines. |
| 165 | +- **Engine difference tests** – `test_engines_differences.py` compares outputs between engines to catch regressions. |
| 166 | + |
| 167 | +### Writing tests |
| 168 | + |
| 169 | +- Add unit tests alongside the module you are changing, following the existing patterns. |
| 170 | +- Add cross-engine test cases to `test_exports.json` when adding or modifying output behavior. Each test case needs a `label`, a `content_state`, and expected `output` for all five engines. |
| 171 | +- All output changes should be covered with unit tests, and integration tests, and snapshot tests. |
| 172 | + |
| 173 | +## Pull request workflow |
| 174 | + |
| 175 | +1. Create a branch from `main` with a descriptive name. |
| 176 | +2. Make your changes, following the coding style and testing guidelines above. |
| 177 | +3. Run `just lint` and `just test` locally to verify everything passes. |
| 178 | +4. Open a pull request with a clear description of what the change does and why. Include relevant test evidence (commands and their output) and links to related issues. |
| 179 | +5. CI will run linting (ruff, mypy, ty, prettier), benchmarks, test coverage, and the compatibility test suite. All checks must pass before merging. |
| 180 | +6. Squash merge when approved. Keep the commit message concise, in the imperative mood, and using Sentence case (no Title Case). |
| 181 | + |
| 182 | +## Releases |
49 | 183 |
|
50 | 184 | - Make a new branch for the release of the new version. |
51 | 185 | - Update the [CHANGELOG](https://github.com/wagtail/draftjs_exporter/CHANGELOG.md). |
@@ -77,7 +211,3 @@ env PYTHON_CONFIGURE_OPTS='--enable-optimizations --with-lto' PYTHON_CFLAGS='-ma |
77 | 211 | ### Static typing |
78 | 212 |
|
79 | 213 | All exporter code should pass static type checking by [mypy](https://mypy.readthedocs.io/en/latest/index.html), with as strict of a configuration as possible, and tentatively also pass type checks with the [ty](https://docs.astral.sh/ty/) checker. |
80 | | - |
81 | | -## Documentation |
82 | | - |
83 | | -> See the [docs](https://github.com/wagtail/draftjs_exporter/tree/main/docs) folder. |
|
0 commit comments