diff --git a/.claude/skills/winged/SKILL.md b/.claude/skills/winged/SKILL.md new file mode 100644 index 0000000..149a8b3 --- /dev/null +++ b/.claude/skills/winged/SKILL.md @@ -0,0 +1,56 @@ +--- +name: winged +description: Write or extend Winged-Python, a dependency-free Python DSL that builds HTML strings. Use when working with winged, Winged-Python, `from winged import`, Element/Document/Fragment/RawHtml/RenderOptions, the `winged` CLI (new/build/serve), or any task that generates HTML, a static site, a sitemap, or an RSS feed with this library. +--- + +# Winged-Python + +A DSL that builds an HTML **string**. No DOM, no browser, no server, no runtime +dependencies. + +## Which workflow are you in? + +**A. Using the library** — building pages, a site, a feed. Read the rules below, then +`references/recipes.md` for the shape you need. + +**B. Extending the library** — changing `src/winged/`. Read `AGENTS.md` at the repository +root; it has the element-adding contract and the "before you say you are done" gate. + +## Rules + +1. **Children are positional, attributes are keyword.** `Div(P("a"), cls="card")`. +2. **Text is escaped.** Pass a plain `str`. Never pre-escape — you get `<`. +3. **`RawHtml` is the only opt-out**, and never for anything from data. +4. **Void elements take no children** and raise if given one: `Br`, `Img`, `Input`, + `Link`, `Meta`, `Hr`, `Col`, `Source`, `Track`, `Wbr`, `Base`, `Embed`. +5. **`Fragment` groups; `RawHtml` injects.** `Fragment` keeps the tree and its + indentation, `RawHtml` loses both. +6. **Boolean attributes are `True`**, not `"true"`. `False`/`None` omit the attribute. +7. **Names that collide take a trailing underscore**: `cls`, `for_`, `type_`, `id_`. + The rendered attribute keeps the HTML spelling. +8. **`Document` owns the doctype and `lang`.** Never write `` by hand. +9. **`render(node, options)` is a function and returns the string.** It does not print. + Options are a value; there is no global. +10. **Iterables are flattened and `None` children are dropped** — that is how loops and + conditionals work: `Ul(Li(x) for x in items)`, `Div(x if cond else None)`. +11. **`Img` requires `alt`; `Iframe` requires `title`.** Deliberate. Do not work around it. +12. **Never invent an element name.** Check `references/tag-catalog.md`. + +## Read it when + +| File | Read it when | +| --- | --- | +| `references/tag-catalog.md` | You need an element name or its signature. **Generated from the source, so it cannot drift.** | +| `references/recipes.md` | You need the shape for a page, layout, component, table, form, media, SEO head, sitemap, feed, or writing to disk. | +| `references/pitfalls.md` | Output is wrong, something is double-escaped, or a 0.1.0 name is missing. | + +## Minimal example + +```python +from winged import Body, Document, H1, Head, P, Title, render + +page = Document(Head(Title("Home")), Body(H1("Hi"), P("")), lang="pt-BR") +print(render(page)) +# +# Home

Hi

<ok>

+``` diff --git a/.claude/skills/winged/references/pitfalls.md b/.claude/skills/winged/references/pitfalls.md new file mode 120000 index 0000000..1db8a43 --- /dev/null +++ b/.claude/skills/winged/references/pitfalls.md @@ -0,0 +1 @@ +../../../../docs/pitfalls.md \ No newline at end of file diff --git a/.claude/skills/winged/references/recipes.md b/.claude/skills/winged/references/recipes.md new file mode 120000 index 0000000..ca3e0e0 --- /dev/null +++ b/.claude/skills/winged/references/recipes.md @@ -0,0 +1 @@ +../../../../docs/recipes.md \ No newline at end of file diff --git a/.claude/skills/winged/references/tag-catalog.md b/.claude/skills/winged/references/tag-catalog.md new file mode 120000 index 0000000..cd41d0d --- /dev/null +++ b/.claude/skills/winged/references/tag-catalog.md @@ -0,0 +1 @@ +../../../../docs/tag-catalog.md \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c71a216 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @micheltlutz diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index dd84ea7..9d7c855 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,38 +1,27 @@ --- name: Bug report -about: Create a report to help us improve -title: '' -labels: '' -assignees: '' - +about: Markup that is wrong, or an error you did not expect +labels: bug --- -**Describe the bug** -A clear and concise description of what the bug is. +## What happened + + + +## What you expected -**To Reproduce** -Steps to reproduce the behavior: -1. Go to '...' -2. Click on '....' -3. Scroll down to '....' -4. See error + -**Expected behavior** -A clear and concise description of what you expected to happen. +## Minimal reproduction -**Screenshots** -If applicable, add screenshots to help explain your problem. +```python +from winged import Div, render -**Desktop (please complete the following information):** - - OS: [e.g. iOS] - - Browser [e.g. chrome, safari] - - Version [e.g. 22] +print(render(Div("..."))) +``` -**Smartphone (please complete the following information):** - - Device: [e.g. iPhone6] - - OS: [e.g. iOS8.1] - - Browser [e.g. stock browser, safari] - - Version [e.g. 22] +## Environment -**Additional context** -Add any other context about the problem here. +- Winged-Python version: +- Python version: +- OS: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1787bba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Question + url: https://github.com/micheltlutz/Winged-Python/discussions + about: Ask how to do something with the library. diff --git a/.github/ISSUE_TEMPLATE/custom.md b/.github/ISSUE_TEMPLATE/custom.md deleted file mode 100644 index 48d5f81..0000000 --- a/.github/ISSUE_TEMPLATE/custom.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Custom issue template -about: Describe this issue template's purpose here. -title: '' -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..f77181b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,20 +1,20 @@ --- name: Feature request -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' - +about: Something the library cannot express, or expresses badly +labels: enhancement --- -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] +## The problem + + + +## What you do today -**Describe the solution you'd like** -A clear and concise description of what you want to happen. + -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. +## What you would like to write -**Additional context** -Add any other context or screenshots about the feature request here. +```python +# The call site you wish existed. +``` diff --git a/.github/ISSUE_TEMPLATE/parity_gap.md b/.github/ISSUE_TEMPLATE/parity_gap.md new file mode 100644 index 0000000..61dda3e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/parity_gap.md @@ -0,0 +1,17 @@ +--- +name: Parity gap with Winged-Swift +about: Winged-Swift does something this port does not, or does differently +labels: parity +--- + +## The Winged-Swift behaviour + + + +## What Winged-Python does instead + + + +## Is the difference deliberate? + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..4b6b0bd --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,18 @@ +## What changed + + + +## Why + + + +## Markup impact + + + +## Checklist + +- [ ] `./scripts/verify.sh` passes +- [ ] Tests cover the change, and assert on rendered strings rather than tree shape +- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]` +- [ ] If an element was added: a row in `winged/_tagtable.py`, and both generators re-run diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 43b9089..ff2b7e0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,15 @@ version: 2 updates: - - package-ecosystem: "pip" - directory: "/" + - package-ecosystem: pip + directory: / schedule: - interval: "weekly" + interval: weekly + open-pull-requests-limit: 10 + + # Without this, the workflows stay pinned wherever they were written. That is exactly + # how Winged-Python ended up on actions/checkout@v2 three years after v4 shipped. + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly open-pull-requests-limit: 10 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..dfaeadf --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +# A new push supersedes the run already in flight. Winged-Swift's workflows have no +# concurrency group, so its queue fills with runs nobody will read. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}, ${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + # Every version pyproject.toml carries a classifier for. A claim nothing runs + # is a claim, not support. + python: ["3.10", "3.11", "3.12", "3.13", "3.14"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - run: pip install -e . pytest pytest-cov + - run: pytest -q + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install ruff + # Gating, deliberately. Winged-Swift marks its SwiftLint job continue-on-error, so + # its badge can be green while lint is failing. + - run: ruff check --output-format=github . + - run: ruff format --check . + + types: + name: Types + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . mypy pytest + - run: mypy + + generated: + name: Generated files are current + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . + # A tag added without regenerating fails here rather than drifting silently. + - run: python scripts/generate_elements.py --check + - run: python scripts/generate_tag_catalog.py --check + + verify: + name: verify.sh + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . pytest pytest-cov ruff mypy build + - run: ./scripts/verify.sh + + coverage: + name: Coverage + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install -e . pytest pytest-cov + - run: pytest -q + - uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.xml + fail_ci_if_error: false diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml deleted file mode 100644 index f17dcaf..0000000 --- a/.github/workflows/python-package.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Python Tests with pytest - -on: - push: - branches: - - main # Substitua 'main' pelo nome da sua branch principal - -jobs: - test: - name: Test on Python 3.11 - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Python 3.11 - uses: actions/setup-python@v2 - with: - python-version: 3.11 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests with pytest and coverage - run: | - pytest - - - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v3 - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ddd732e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,64 @@ +name: Release + +on: + push: + tags: ["v*.*.*"] + +jobs: + release: + name: Build, verify and publish + runs-on: ubuntu-latest + environment: release + permissions: + contents: write + id-token: write # PyPI trusted publishing. No long-lived token in secrets. + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - run: pip install -e . pytest pytest-cov ruff mypy build + - name: Verify + run: ./scripts/verify.sh + + - name: Build distributions + run: | + rm -rf dist + python -m build + + # A tag is the only trigger here, so everything downstream trusts it. These three + # checks are what make that safe: a tag that disagrees with pyproject.toml, or that + # the changelog has never heard of, fails before anything is published. + - name: Check the tag, pyproject.toml and CHANGELOG.md agree + run: | + VERSION="${GITHUB_REF_NAME#v}" + PACKAGED=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])") + if [ "$VERSION" != "$PACKAGED" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match pyproject.toml version $PACKAGED" + exit 1 + fi + grep -qF "## [$VERSION]" CHANGELOG.md || { + echo "::error::CHANGELOG.md has no '## [$VERSION]' section" + exit 1 + } + + - name: Extract this version's changelog section + id: notes + run: | + VERSION="${GITHUB_REF_NAME#v}" + awk -v v="## [$VERSION]" ' + index($0, v) == 1 { inside = 1; next } + inside && /^## \[/ { exit } + inside { print } + ' CHANGELOG.md > RELEASE_NOTES.md + # An empty body would publish a release nobody can read. + [ -s RELEASE_NOTES.md ] || { echo "::error::release notes came out empty"; exit 1; } + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: softprops/action-gh-release@v2 + with: + body_path: RELEASE_NOTES.md + files: dist/* + + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index eaa90ce..652cba8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,250 +1,24 @@ -./import_maker.py -./demo.py - -### macOS ### -# General -.DS_Store -.AppleDouble -.LSOverride - -# Icon must end with two \r -Icon - -# Thumbnails -._* - -# Files that might appear in the root of a volume -.DocumentRevisions-V100 -.fseventsd -.Spotlight-V100 -.TemporaryItems -.Trashes -.VolumeIcon.icns -.com.apple.timemachine.donotpresent - -# Directories potentially created on remote AFP share -.AppleDB -.AppleDesktop -Network Trash Folder -Temporary Items -.apdisk - -### macOS Patch ### -# iCloud generated files -*.icloud - -### Python ### -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python +# Build artefacts build/ -develop-eggs/ dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ *.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock +.verify-dist/ -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/#use-with-ide -.pdm.toml - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ +# Python +__pycache__/ +*.py[cod] +.venv/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject -# mkdocs documentation -/site - -# mypy +# Tooling +.pytest_cache/ .mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -### Python Patch ### -# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration -poetry.toml - -# ruff .ruff_cache/ +.coverage +coverage.xml +htmlcov/ -# LSP config files -pyrightconfig.json - -### venv ### -# Virtualenv -# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ -[Bb]in -[Ii]nclude -[Ll]ib -[Ll]ib64 -[Ll]ocal -[Ss]cripts -pyvenv.cfg -pip-selfcheck.json - -### Windows ### -# Windows thumbnail cache files -Thumbs.db -Thumbs.db:encryptable -ehthumbs.db -ehthumbs_vista.db - -# Dump file -*.stackdump - -# Folder config file -[Dd]esktop.ini - -# Recycle Bin used on file shares -$RECYCLE.BIN/ - -# Windows Installer files -*.cab -*.msi -*.msix -*.msm -*.msp - -# Windows shortcuts -*.lnk - -# pytest cache -.cache/ - - -# End of https://www.toptal.com/developers/gitignore/api/python,windows,macos,venv \ No newline at end of file +# Editors / OS +.idea/ +.vscode/ +.DS_Store diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 26d3352..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml diff --git a/.idea/Winged-Python.iml b/.idea/Winged-Python.iml deleted file mode 100644 index 0bc6bf6..0000000 --- a/.idea/Winged-Python.iml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index aefa39b..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,31 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml deleted file mode 100644 index 105ce2d..0000000 --- a/.idea/inspectionProfiles/profiles_settings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 7677182..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 54c93f2..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..79e0d73 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,158 @@ +# AGENTS.md — working with Winged-Python + +Instructions for anyone changing this repository, human or coding agent. + +## What Winged-Python is + +A dependency-free DSL that builds an HTML **string**. No DOM, no browser, no server. You +compose elements into a tree and render it to text. + +Three things follow from that, and most mistakes come from forgetting one of them: + +1. **The output is a string**, so tests assert on rendered strings — never on tree shape. +2. **Escaping is the whole security surface.** Text and attribute values are escaped by + default; `RawHtml` is the only opt-out and must never receive user input. +3. **The tree has reference semantics.** Putting one element in two parents shares the + node; it is not copied. + +```python +from winged import Body, Document, H1, Head, P, Title, render + +page = Document(Head(Title("Home")), Body(H1("Hi"), P("")), lang="pt-BR") +render(page) +# +# Home

Hi

<ok>

+``` + +## Commands + +| Task | Command | +| --- | --- | +| Everything, before you say you are done | `./scripts/verify.sh` | +| Tests | `pytest` | +| One test file | `pytest tests/test_element.py -q` | +| Lint and format | `ruff check . && ruff format .` | +| Types | `mypy` | +| Regenerate the elements | `python scripts/generate_elements.py` | +| Regenerate the tag catalogue | `python scripts/generate_tag_catalog.py` | +| Regenerate the golden fixtures | `WINGED_UPDATE_FIXTURES=1 pytest tests/test_golden.py` | + +## Repository map + +``` +src/winged/ + core/ + escape.py escape_text / escape_attribute / escape_xml — the security surface + attribute.py Attribute, and the boolean-attribute set + element.py Element: the tree, the chainable helpers, the pretty printer + node.py Text, RawHtml, Fragment, Comment, and child coercion + render.py RenderOptions, the Node protocol, render() + tags.py VOID_ELEMENTS and WHITESPACE_SENSITIVE + _tagtable.py the one declarative table every element is generated from + elements.py GENERATED — do not edit + _special.py Img and Iframe, whose signatures require an argument + document.py Document: doctype and + layout.py the Layout protocol + seo.py Open Graph, Twitter Cards, SeoBuilder + sitemap.py sitemap 0.9 + feed.py RSS 2.0 + ssg.py StaticSiteGenerator + accessibility.py the audit + cli.py winged new / build / serve + templates/ what `winged new` scaffolds +scripts/ the two generators, the Markdown link check, and verify.sh +tests/fixtures/ Winged-Swift's golden files, copied +docs/ tag-catalog.md is generated; recipes and pitfalls are not +``` + +## Rules + +1. **Prefer the varargs form.** `Div(P("a"), cls="x")`, not `Div().child(P("a"))`. The + chainable helpers are for the cases where the value is computed. +2. **Never invent an element name.** Check `docs/tag-catalog.md`. If it is not there, add + a row to `_tagtable.py` and regenerate — see the contract below. +3. **Content and attributes are escaped for you.** Never pre-escape; you will get + `&lt;`. +4. **`RawHtml` is the only way in for markup**, and never for anything from data. +5. **Void elements take no children.** `Br`, `Img`, `Input`, `Link`, `Meta`, `Hr`, `Col`, + `Source`, `Track`, `Wbr`, `Base`, `Embed`. Passing one a child raises. +6. **`Fragment` groups, `RawHtml` injects.** `Fragment` keeps the tree and its + indentation; `RawHtml` flattens to a string and loses both. +7. **Boolean attributes are `True`**, not `"true"`. `False` and `None` omit the attribute. +8. **`Document` owns the doctype.** Never write `` by hand. +9. **Rendering is configured by value.** Pass `RenderOptions`; never add a global. +10. **Names that collide take a trailing underscore** — `cls`, `for_`, `type_`, `id_`. + The rendered attribute keeps the HTML spelling. +11. **`Img` requires `alt` and `Iframe` requires `title`.** That is deliberate; do not + add defaults. + +## Recipes + +### A full page written to disk + +```python +from winged import Body, Document, H1, Head, Link, Main, Title +from winged.seo import SeoBuilder +from winged.ssg import StaticSiteGenerator + +head = Head( + SeoBuilder(title="Home", description="…", image="…", url="…").build(), + Title("Home"), + Link(href="/css/style.css", rel="stylesheet"), +) +page = Document(head, Body(Main(H1("Home"))), lang="pt-BR") + +site = StaticSiteGenerator("dist") +site.clean() +site.generate(page, "index.html") +``` + +### A reusable component + +A component is a function returning a node. There is no base class to inherit. + +```python +from winged import A, Div, H3, P +from winged.core.render import Node + + +def card(title: str, body: str, href: str) -> Node: + return Div(H3(title), P(body), A("Read more", href=href), cls="card") +``` + +## Adding an element to the library + +1. Add a row to `src/winged/_tagtable.py`: `("Name", "tag", "Group")`. +2. If it is void, add the tag to `VOID_ELEMENTS` in `src/winged/core/tags.py`. If its + content is whitespace-sensitive, add it to `WHITESPACE_SENSITIVE`. +3. Run `python scripts/generate_elements.py` and + `python scripts/generate_tag_catalog.py`. Commit both generated files. +4. `tests/test_tag_catalog.py` picks the element up automatically — confirm it passes. +5. Add a `CHANGELOG.md` entry under `## [Unreleased]`. + +An element whose signature must require an argument (like `Img`) goes in `_special.py` +instead, and gets a row in the generator's `names` list. + +## Conventions + +- Four-space indent, 100-column lines, `ruff format`. +- A docstring on every public module, class and function. +- Comments explain **why**, not what. If a line encodes a decision, say what the + alternative was and why it lost. +- Tests are pytest: plain functions, `parametrize`, `capsys`. No `unittest.TestCase`. +- Assert on rendered strings, never on private attributes. +- Commit messages: `Add:`, `Fix:`, `Change:`, `Docs:`. +- English, in code and comments — the Swift sibling is in English too. + +## Before you say you are done + +```bash +./scripts/verify.sh +``` + +It builds, tests, lints, type-checks, verifies both generated files are current and that +`.claude/skills/winged/references/` is still symlinked into `docs/`, resolves every +relative link in the Markdown, checks byte-for-byte parity against Winged-Swift's fixtures, installs the +package into a throwaway venv and renders a page with it, then runs `winged new` and +`winged build` **from `/`** — because a generator that resolves paths from the current +directory passes every unit test and still fails for the user. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..fd48553 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,107 @@ +# Changelog + +All notable changes to Winged-Python are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-09-21 + +Parity with [Winged-Swift](https://github.com/micheltlutz/Winged-Swift) 2.0.0, demonstrated +rather than claimed: `tests/test_golden.py` reproduces all four of Winged-Swift's golden +fixtures byte for byte. + +**This release is a clean break from 0.1.0.** Nothing from the old API survives. See +[MIGRATION.md](MIGRATION.md) for the replacement of every removed name, and +[PORTING.md](PORTING.md) for the map from Winged-Swift. + +### Added + +- **HTML escaping** — `escape_text`, `escape_attribute` and `escape_xml`. 0.1.0 escaped + nothing at all: `String.get_string()` returned its text verbatim and attribute values + were interpolated straight into `key="value"`. Text children are escaped by default and + `RawHtml` is the only opt-out. +- **93 elements**, up from 49, generated from one table in `winged/_tagtable.py`. + `docs/tag-catalog.md` is generated from the same table. +- **`RenderOptions`** — `pretty`, `indent`, `xhtml_self_closing`, passed per call rather + than held as global state. 0.1.0 had no pretty printing; its README showed indented + output the library could not produce. +- **`render` and `render_into`** — `render` returns the markup as a string; `render_into` + writes it straight into a text file object, so memory stays flat on a page large enough + that you would rather not hold it twice. What a node writes into is the `Buffer` + protocol, which is what makes a list and a file interchangeable without any + `write_into` knowing which it got. +- **`Document`** — owns `` and ``. +- **`Fragment`, `RawHtml`, `Comment`, `Text`** — a transparent group, an explicit raw + injection, a comment that refuses `--`, and escaped text. +- **The varargs API** — `Div(H1("x"), P("y"), cls="box")`. Iterables are flattened, so + `Ul(Li(x) for x in items)` works; `None` children are dropped, so + `Div(x if cond else None)` is the conditional form. +- **Chainable helpers** — `add_class`, `add_classes`, `set_id`, `set_style`, `set_role`, + `attr`, `data_attr(s)`, `aria_attr(s)`, each returning `Self`. +- **`seo`** — Open Graph, Open Graph Article, Twitter Cards, the common head set, and + `SeoBuilder`. +- **`sitemap`** and **`feed`** — sitemap 0.9 and RSS 2.0 generators. +- **`ssg.StaticSiteGenerator`** — writes pages and assets. `clean()` refuses the + filesystem root, the home directory, and anything reached through a symlink out of the + output directory. +- **`accessibility.audit`** — eight rules (`img-alt`, `button-label`, `iframe-title`, + `link-text`, `heading-order`, `html-lang`, `form-label`, `duplicate-id`). This is the + check Winged-Swift's `ROADMAP.md` asks for and has not shipped. Three of them are + deliberately not naive: `link-text` looks at every `` under the link rather than + its direct children, so the ordinary `` icon link is checked; + `heading-order` treats `
`, `
`, `