From e4683fbcda6bcb4b8f5c788ef21ed3d86ca925ba Mon Sep 17 00:00:00 2001 From: Helge Date: Sat, 1 Nov 2025 08:40:20 +0100 Subject: [PATCH] Create a FEP static site (#673) ### Preview version https://helge.codeberg.page/fep/ click there to judge ### Tasks done - [x] Add a proper index.md instead of final.md .... needs me to filter the readme.md - [x] search is broken --> will resolve itself when all feps are part of it - [x] Update https://codeberg.org/fediverse/fep/src/branch/main/fep/7d8c/fep-7d8c.md - [x] The admonition format (according to copilot I can blame this format on Microsoft https://learn.microsoft.com/en-us/contribute/content/markdown-reference#alerts-note-important-tip-warning-caution) ``` >[!NOTE] >ActivityPub [requires][ActivityPub-Collections] ordered collections to be presented in reverse chronological order. However, an [erratum][ActivityPub-Errata] was proposed to relax this requirement. ``` is not the one supported by python, see https://python-markdown.github.io/extensions/admonition/ . One can probably resolve this by adapting the code https://github.com/Python-Markdown/markdown/blob/f39cf84a24124526c1a0efbe52219fa9950774f6/markdown/extensions/admonition.py - [x] FEPs miss meta information .... - [x] Meta information for tracking issue and discussion to link should be formatted - [x] copy script has problems with contained directories - [x] add codeberg link to meta, e.g. https://helge.codeberg.page/fep/fep/1b12/ - [x] include implementation counts -> https://codeberg.org/fediverse/fep/issues/686 Co-authored-by: silverpill Reviewed-on: https://codeberg.org/fediverse/fep/pulls/673 Reviewed-by: silverpill Co-authored-by: Helge Co-committed-by: Helge --- .gitignore | 2 + .woodpecker/readme.yml | 3 +- .woodpecker/test.yml | 15 +- .woodpecker/website.yml | 18 +++ README.md | 10 +- fep/7d8c/fep-7d8c.md | 58 +++++++- scripts/.gitignore | 9 ++ scripts/create_issue.py | 5 +- scripts/create_readme.py | 10 +- scripts/create_site_pages.py | 42 ++++++ scripts/docs/assets/codeberg.png | Bin 0 -> 5686 bytes scripts/docs/assets/logo.png | Bin 0 -> 33269 bytes scripts/docs/javascripts/tablesort.js | 6 + .../tablesort@5.3.0/tablesort.min.js | 6 + scripts/docs/styles/theme.css | 9 ++ scripts/fep_tools/__init__.py | 64 ++++++++ scripts/{tools => fep_tools}/fep_file.py | 74 +++++++--- scripts/{tools => fep_tools}/issue.py | 2 +- scripts/fep_tools/site_fep_page.py | 124 ++++++++++++++++ scripts/fep_tools/site_overview_page.py | 39 +++++ scripts/fep_tools/table.py | 138 ++++++++++++++++++ scripts/fep_tools/test_fep_file.py | 13 ++ scripts/{tools => fep_tools}/test_feps.py | 9 +- scripts/{tools => fep_tools}/test_issue.py | 0 scripts/fep_tools/test_json.py | 12 ++ scripts/{tools => fep_tools}/test_readme.py | 8 +- scripts/fep_tools/test_site_fep_page.py | 71 +++++++++ scripts/fep_tools/test_table.py | 33 +++++ scripts/impl_count.py | 8 +- scripts/mkdocs.yml | 56 +++++++ scripts/new_proposal.py | 2 +- scripts/pull_request.py | 2 +- scripts/pyproject.toml | 19 +++ scripts/{ => snippets}/backmatter.md | 0 scripts/snippets/fep_title.md | 4 + scripts/{ => snippets}/frontmatter.md | 20 +-- scripts/snippets/frontmatter_git.md | 6 + scripts/tools/__init__.py | 64 -------- scripts/update_issues.py | 19 ++- 39 files changed, 847 insertions(+), 133 deletions(-) create mode 100644 .woodpecker/website.yml create mode 100755 scripts/create_site_pages.py create mode 100644 scripts/docs/assets/codeberg.png create mode 100644 scripts/docs/assets/logo.png create mode 100644 scripts/docs/javascripts/tablesort.js create mode 100644 scripts/docs/javascripts/tablesort@5.3.0/tablesort.min.js create mode 100644 scripts/docs/styles/theme.css create mode 100644 scripts/fep_tools/__init__.py rename scripts/{tools => fep_tools}/fep_file.py (54%) rename scripts/{tools => fep_tools}/issue.py (97%) create mode 100644 scripts/fep_tools/site_fep_page.py create mode 100644 scripts/fep_tools/site_overview_page.py create mode 100644 scripts/fep_tools/table.py create mode 100644 scripts/fep_tools/test_fep_file.py rename scripts/{tools => fep_tools}/test_feps.py (88%) rename scripts/{tools => fep_tools}/test_issue.py (100%) create mode 100644 scripts/fep_tools/test_json.py rename scripts/{tools => fep_tools}/test_readme.py (62%) create mode 100644 scripts/fep_tools/test_site_fep_page.py create mode 100644 scripts/fep_tools/test_table.py create mode 100644 scripts/mkdocs.yml create mode 100644 scripts/pyproject.toml rename scripts/{ => snippets}/backmatter.md (100%) create mode 100644 scripts/snippets/fep_title.md rename scripts/{ => snippets}/frontmatter.md (67%) create mode 100644 scripts/snippets/frontmatter_git.md delete mode 100644 scripts/tools/__init__.py diff --git a/.gitignore b/.gitignore index bee8a64..d9bf8cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ __pycache__ +.cache +venv diff --git a/.woodpecker/readme.yml b/.woodpecker/readme.yml index 00b1ef7..142953a 100644 --- a/.woodpecker/readme.yml +++ b/.woodpecker/readme.yml @@ -1,10 +1,11 @@ when: - branch: [main] event: push + repo: fediverse/fep steps: build: - image: python:3.11-alpine + image: python:3.13-alpine commands: - ./scripts/update_issues.py - ./scripts/create_readme.py diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml index 3818563..bde8ac4 100644 --- a/.woodpecker/test.yml +++ b/.woodpecker/test.yml @@ -3,8 +3,19 @@ when: event: [pull_request, push] steps: - build: - image: python:3.11-alpine + check_create_readme_works: + image: python:3.13-alpine + commands: + - ./scripts/create_readme.py + + check_new_proposal_works: + image: python:3.13-alpine + commands: + - ./scripts/new_proposal.py Your Title Goes Here + - rm -rf fep/7f9d + + run_pytest: + image: python:3.13-alpine commands: - pip install pytest - pytest diff --git a/.woodpecker/website.yml b/.woodpecker/website.yml new file mode 100644 index 0000000..62822d3 --- /dev/null +++ b/.woodpecker/website.yml @@ -0,0 +1,18 @@ +when: + - branch: [main] + event: push + +steps: + build: + image: python:3.13-alpine + commands: + - pip install ./scripts + - pytest + - ./scripts/create_site_pages.py + - mkdocs build --config-file scripts/mkdocs.yml + deploy_pages: + image: codeberg.org/xfix/plugin-codeberg-pages-deploy:1 + settings: + folder: scripts/site + ssh_key: + from_secret: deploy_key diff --git a/README.md b/README.md index 901d2fd..7034c84 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ $ echo -n "The title of my proposal" | sha256sum | cut -c-4 b3f0 ``` -4. Create a subdirectory of [`fep/`](./fep/) using the identifier you just computed. -5. Copy the FEP template ([fep-xxxx-template.md](./fep-xxxx-template.md)) to this subdirectory and change the filename appropriately. +4. Create a subdirectory of [`fep/`](https://codeberg.org/fediverse/fep/src/branch/main/fep/) using the identifier you just computed. +5. Copy the FEP template ([fep-xxxx-template.md](https://codeberg.org/fediverse/fep/src/branch/main/fep-xxxx-template.md)) to this subdirectory and change the filename appropriately. 6. Use the identifer as the "slug" when filling out the frontmatter. - For example, if your computed identifier was `abcd`, then your file would be located at `fep/abcd/fep-abcd.md` and your frontmatter would include `slug: "abcd"`. @@ -33,7 +33,7 @@ b3f0 8. When you are ready to submit the FEP, change the value of `dateReceived` field in the frontmatter to the current date. 9. Create a discussion topic for your FEP. You can use [ActivityPub category](https://socialhub.activitypub.rocks/c/activitypub/5) on the SocialHub forum. 10. Add `discussionsTo` field containing the URL of the discussion topic to the front matter of your FEP. -11. Create a Pull Request to complete Step 1 of [FEP-a4ed: The Fediverse Enhancement Proposal Process](./fep/a4ed/fep-a4ed.md). Further process is described in FEP-a4ed. +11. Create a Pull Request to complete Step 1 of [FEP-a4ed: The Fediverse Enhancement Proposal Process](https://codeberg.org/fediverse/fep/src/branch/main/fep/a4ed/fep-a4ed.md). Further process is described in FEP-a4ed. Alternatively to steps 3. to 6., you can run @@ -45,7 +45,7 @@ that should create a prefilled template for you. ## Facilitators -The list of FEP's is maintained by the facilitators who are listed in the [FACILITATORS.md](FACILITATORS.md) file. Facilitators are neutral custodians of the FEP process, who merge PR's and create tracking issues. +The list of FEP's is maintained by the facilitators who are listed in the [FACILITATORS.md](https://codeberg.org/fediverse/fep/src/branch/main/FACILITATORS.md) file. Facilitators are neutral custodians of the FEP process, who merge PR's and create tracking issues. ## Contributing @@ -53,8 +53,6 @@ Do you have ideas to improve the FEP Process? Post your suggestions to the issue ## FEPs - - | Title | Status | Tracking issue | `dateReceived` | `dateFinalized` (or `dateWithdrawn`) | | --- | --- | ----- | ------- | ------ | | [FEP-a4ed: The Fediverse Enhancement Proposal Process](./fep/a4ed/fep-a4ed.md) | `FINAL` | [#201](https://codeberg.org/fediverse/fep/issues/201) | 2020-10-16 | 2021-01-18 | diff --git a/fep/7d8c/fep-7d8c.md b/fep/7d8c/fep-7d8c.md index 99a1591..c7a68ea 100644 --- a/fep/7d8c/fep-7d8c.md +++ b/fep/7d8c/fep-7d8c.md @@ -39,15 +39,22 @@ Automation for the FEP repository uses the [codeberg woodpecker](https://ci.code - test runs basic checks on the contained FEPs - readme updates the `README.md` file and creates the tracking issue +- create a static website Automation code is written in python and is contained in the [script folder](https://codeberg.org/fediverse/fep/src/branch/main/scripts). +### Scripts and tests + +FEP uses python scripts to manage some aspects of it. These should be runnable +without installing packages. In order to ensure these scripts stay working, +include them in [.woodpecker/test.yml](https://codeberg.org/fediverse/fep/src/branch/main/.woodpecker/test.yml) + ## Configuration variables To be able to do these configuration tasks, you need to be in the __admin__ group. See the first discussion [here](https://socialhub.activitypub.rocks/t/fep-repo-rights/4797/2). -The configuration [.woodpecker/readme.yml](https://codeberg.org/fediverse/fep/src/branch/main/.woodpecker/woodpecker.yml) uses two secrets: +The configuration [.woodpecker/readme.yml](https://codeberg.org/fediverse/fep/src/branch/main/.woodpecker/readme.yml) uses two secrets: - codeberg_api_token, a codeberg API token used to create the tracking issues. - deploy_key, an SSH private key used to push to the codeberg repository. @@ -74,7 +81,7 @@ ssh-keygen -t ed25519 -f deploy_key Do not specify a passphrase. Examples: -``` bash +```bash $ cat deploy_key -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW @@ -97,9 +104,56 @@ One can add these secrets on [woodpecker](https://ci.codeberg.org/repos/12388), The deploy_key needs to be end with a new line when pasting into the woodpecker ui. +## Development + +The tools are located in the `scripts` folder, with the `scripts/fep_tools` folder containing the python package `fep_tools`. Most of this tooling only uses python, e.g. +`scripts/new_proposal.py` and `scripts/create_readme.md`, so they can be run from any +environment. + +### Running tests + +To run the tests, one needs [pytest](https://docs.pytest.org/). We note that the +tests run from the base folder. The following commands demonstrate how to setup +a virtual environment, install the dependencies, and run the tests. + +```bash +python -mvenv .venv +pip install ./scripts +pytest +``` + +One can leave the venv, by running `deactivate`. + + +### Building the website + +To build the website, first install the dependencies + +```bash +python -mvenv .venv +pip install ./scripts +./scripts/create_site_pages.py +``` + +Then run mkdocs via + +```bash +mkdocs serve --config-file scripts/mkdocs.yml +``` + +References: + +- The website is build with [mkdocs](https://www.mkdocs.org/) +- and uses [mkdocs-material](https://squidfunk.github.io/mkdocs-material/) for its many plugins + +### Automatic deployment + +See [website.yml](https://codeberg.org/fediverse/fep/src/branch/main/.woodpecker/website.yml). + ## Changes - Added discussion link to tracking issue, use table to format (@helge, 2025-03-07) +- Added static website stuff [fep#673](https://codeberg.org/fediverse/fep/pulls/673) ## References diff --git a/scripts/.gitignore b/scripts/.gitignore index 665f454..fa23a83 100644 --- a/scripts/.gitignore +++ b/scripts/.gitignore @@ -1 +1,10 @@ config*.json +site +docs/fep +docs/withdrawn.md +docs/final.md +docs/draft.md +docs/index.md +build +fep.egg-info +uv.lock diff --git a/scripts/create_issue.py b/scripts/create_issue.py index ca80976..6961383 100755 --- a/scripts/create_issue.py +++ b/scripts/create_issue.py @@ -1,7 +1,7 @@ #!/usr/bin/env python from argparse import ArgumentParser -from tools.issue import create_issue +from fep_tools.issue import create_issue import json @@ -12,5 +12,4 @@ args = parser.parse_args() with open("scripts/config.json") as f: config = json.load(f) -create_issue(config['owner'], config['repo'], config['token'], args.fep) - +create_issue(config["owner"], config["repo"], config["token"], args.fep) diff --git a/scripts/create_readme.py b/scripts/create_readme.py index 0269bd7..08fd0ba 100755 --- a/scripts/create_readme.py +++ b/scripts/create_readme.py @@ -2,17 +2,11 @@ import json -from tools import index, Readme +from fep_tools import data_for_json_file, Readme with open("README.md", "w") as f1: f1.writelines(Readme().content) -result = [] -for fep in index(): - data = fep.parsed_frontmatter - data["title"] = fep.title - data["implementations"] = fep.implementations - result.append(data) with open("index.json", "w") as index_file: - json.dump(result, index_file, indent=2) + json.dump(data_for_json_file(), index_file, indent=2) diff --git a/scripts/create_site_pages.py b/scripts/create_site_pages.py new file mode 100755 index 0000000..b1ea338 --- /dev/null +++ b/scripts/create_site_pages.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python + +from fep_tools import index +from fep_tools.site_fep_page import make_page_for_fep +from fep_tools.site_overview_page import write_overview_file + +from fep_tools import Readme + +path_prefix = "scripts/docs" + +with open(f"{path_prefix}/index.md", "w") as fp: + fp.write("""--- +hide: +- navigation +--- + +# Fediverse Enhancement Proposals + +
+ +- [:material-file-document: Final Proposals](./final.md) +- [:material-file-document-edit: Draft Proposals](./draft.md) + +
+ +""") + + fp.writelines(Readme(include_fep_table=False).content) + + +for file, status in [ + ("index.html", "FINAL"), + ("draft.html", "DRAFT"), + ("withdrawn.html", "WITHDRAWN"), +]: + filename = f"{path_prefix}/{status.lower()}.md" + + write_overview_file(filename, status) + + +for fep in index(): + make_page_for_fep(fep) diff --git a/scripts/docs/assets/codeberg.png b/scripts/docs/assets/codeberg.png new file mode 100644 index 0000000000000000000000000000000000000000..0d5d02eec7684a9da86ee8dd0ca1f2392fd06cc3 GIT binary patch literal 5686 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

cW=+Ik8gCeH5x10$}u}SB1KxU}p<<4jo9X zQ-L8>ExxoG8q4j1u{=@Y2-5(161|7cpXmt3&4H&PFSgDkWzI9Q!Vk0dr4Ix-4DtT` zM^DK*^@MmodA)+41eK5|m@A$XRBgd)|R5xJp3GVac8<`-`e486IO!v%ux^wqO&_)=XeepPX8Sh_!#@JxRmo z)0`a>vTOeyA##!yA%hX~lMf2m>HDH4GX%gINQ5zZSUbime$jNoMEqJ%h9UwDq5Q8~ zBps5|2T9+5x)OON@E*JAr68!IEQU_{R4Zxs(Mb-f=jQXF|D7YVkMwC`(p(8&P77Ft ze1BxcsqkvR@bFF`{sQGZ9vegop+o8iQu&D_FEOt2bsJ;YTV>oX8!{;o`nw#1nR?Y@ zIz#X77wNL>*6xx|;RBL|7aM$)0kW)RFt62n6E)}-)UJk)#$DUGo!zs_xX2SR?4P%) zBQq=jUKvX0(LO0z621@OSt z01Sp!MtR#$yE3x8*kTAHXQjq#y*?;o!BNIDGK+=Ai*rG!yw|t!Q?&oPPcvxZa4nHT zi_@sqN#Zzu@Jf+w(=He)avs^Xjo(UrH0;my!wycGk@bx^3vvn583r6GS>kB9A46cR zIYoPuUZUw()9hW{%8OSWgDa=nPx+j^-)^P-;{lx?--gOxH{r~jeUk!Fxo62z=g;7+bvfAcTF(R55S2;$=NlF@z&i@# jH0$3$-2c}0>_bRMmJYM$taFYp|5E8`8EJk{cYysD3xW+S literal 0 HcmV?d00001 diff --git a/scripts/docs/assets/logo.png b/scripts/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6d958d06f529a77b985c157a544a57bf0847dc69 GIT binary patch literal 33269 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.tHead&&a.tHead.rows.length>0){for(e=0;e0&&n.push(m),o++;if(!n)return}for(o=0;o list[dict[str, str]]: + result = [] + for fep in index(): + data = fep.parsed_frontmatter + data["title"] = fep.title + data["implementations"] = fep.implementation_count + result.append(data) + + return result diff --git a/scripts/tools/fep_file.py b/scripts/fep_tools/fep_file.py similarity index 54% rename from scripts/tools/fep_file.py rename to scripts/fep_tools/fep_file.py index 6e50c4b..37b7225 100644 --- a/scripts/tools/fep_file.py +++ b/scripts/fep_tools/fep_file.py @@ -1,22 +1,38 @@ -def unquote(value): +from dataclasses import dataclass, field + + +def unquote(value: str) -> str: if value.startswith('"') and value.endswith('"'): return value[1:-1] else: return value +@dataclass class FepFile: - def __init__(self, fep): - self.fep = fep + fep: str + frontmatter: list[str] = field(default_factory=list) + content: list[str] = field(default_factory=list) + + def __post_init__(self): with open(self.filename) as f: - self.frontmatter, self.content = FepFile.parsefile(f) + lines = f.readlines() + + status = 0 + + for line in lines: + if line == "---\n" and status <= 2: + status += 1 + elif status == 1: + self.frontmatter.append(line.removesuffix("\n")) + elif status >= 2: + self.content.append(line.removesuffix("\n")) @property - def filename(self): + def filename(self) -> str: return f"fep/{self.fep}/fep-{self.fep}.md" - @property - def summary(self): + def find_section_by_name(self, name): result = [] is_summary = False for x in self.content: @@ -24,9 +40,13 @@ class FepFile: if x.startswith("##"): return "\n".join(result) result.append(x) - elif x == "## Summary": + elif x == f"## {name}": is_summary = True + @property + def summary(self): + return self.find_section_by_name("Summary") + def write(self): with open(self.filename, "w") as f: f.write("---\n") @@ -42,7 +62,11 @@ class FepFile: return {a: unquote(b.strip()) for a, b in split} @property - def title(self): + def status(self): + return self.parsed_frontmatter["status"] + + @property + def title(self) -> str: titles = [x for x in self.content if x.startswith("# ")] assert len(titles) > 0 @@ -56,25 +80,23 @@ class FepFile: return true_title @property - def implementations(self): + def implementation_count(self): if self.parsed_frontmatter.get("type") != "implementation": return 0 implementations = [] in_section = False for line in self.content: - if line.startswith('#') and "Implementations" in line: + if line.startswith("#") and "Implementations" in line: in_section = True - elif in_section is True and line.startswith('#'): + elif in_section is True and line.startswith("#"): in_section = False - elif ( - in_section is True - and (line.startswith("-") or line.startswith("*")) - ): + elif in_section is True and (line.startswith("-") or line.startswith("*")): implementations.append(line) + return len(implementations) @staticmethod - def parsefile(f): + def parsefile(f) -> tuple[list[str], list[str]]: lines = f.readlines() status = 0 @@ -90,3 +112,21 @@ class FepFile: content.append(line.removesuffix("\n")) return frontmatter, content + + def frontmatter_table(self) -> str: + keys = " | ".join(self.parsed_frontmatter.keys()) + values = " | ".join(self.parsed_frontmatter.values()) + divider = " | ".join(["---"] * len(self.parsed_frontmatter)) + + return f""" +| {keys} | +| {divider} | +| {values} | +""" + + def content_and_title(self) -> tuple[str, str]: + for j, line in enumerate(self.content): + if line.startswith("# "): + return line, "\n".join(self.content[j + 1 :]) + + raise Exception("Could not determine title and content") diff --git a/scripts/tools/issue.py b/scripts/fep_tools/issue.py similarity index 97% rename from scripts/tools/issue.py rename to scripts/fep_tools/issue.py index 3c221eb..33dfc43 100644 --- a/scripts/tools/issue.py +++ b/scripts/fep_tools/issue.py @@ -72,7 +72,7 @@ def update_fep_file_with_date_received(fep_file: FepFile, date_received: date): fep_file.frontmatter.append(f"dateReceived: {date_received.isoformat()}") -def create_issue(owner, repo, token, slug): +def create_issue(owner: str, repo: str, token: str, slug: str): fep_file = FepFile(slug) if "trackingIssue" in fep_file.parsed_frontmatter: diff --git a/scripts/fep_tools/site_fep_page.py b/scripts/fep_tools/site_fep_page.py new file mode 100644 index 0000000..87ac9ab --- /dev/null +++ b/scripts/fep_tools/site_fep_page.py @@ -0,0 +1,124 @@ +import re +import shutil +from pathlib import Path + +try: + import nh3 + from markdown import markdown +except Exception: + ... + +from .fep_file import FepFile +from .table import TableLineBuilder + + +def transform_key(x: str) -> str: + if x in ["dateReceived", "dateWithdrawn", "trackingIssue"]: + return re.sub(r"(? str: + """Creates a markdown table containing FEP metadata""" + matter_keys = [transform_key(x) for x in fep.parsed_frontmatter.keys()] + matter_keys = [x for x in possible_matter_keys if x in matter_keys] + ["repository"] + + line_builder = TableLineBuilder(fep=fep, columns=list(matter_keys)) + keys = " | ".join(x.replace("_", " ").capitalize() for x in matter_keys) + values = line_builder.line + + divider = " | ".join(["---"] * len(matter_keys)) + + return f""" +| {keys} | +| {divider} | +{values} +""" + + +def clean_markdown(text: str) -> str: + """Transforms markdown to a plain text string. To be used in the frontmatter""" + transformed = nh3.clean(markdown(text), tags=set()) + transformed = re.sub(r"\[(.+?)\]\[(.+?)\]", r"\1", transformed) + transformed = transformed.replace( + ":", " " + ) # See https://codeberg.org/fediverse/fep/pulls/673/files#issuecomment-7514311 + return transformed.replace("\n", " ") + + +def frontmatter_for_fep(fep: FepFile) -> str: + summary = fep.summary + if summary is None: + return """--- +hide: + - navigation +--- + +""" + description = clean_markdown(summary) + + return f"""--- +hide: + - navigation +description: {description} +--- + +""" + + +def transform_content(content: str) -> str: + """Transforms links to other FEPs. These need to be adjusted due to + the renaming of `fep-slug.md` to `index.md`.""" + content = re.sub( + r"\[(.+?)\]\((\.\./.+)/fep-(.+).md\)", r"[\1](\2/index.md)", content + ) + content = re.sub(r"\[(.+?)\]: (\.\./.+)/fep-(.+).md", r"[\1]: \2/index.md", content) + return content + + +def fep_to_site_mkdocs(fep: FepFile) -> str: + """Transforms the FEP to markdown suitable for publishing as a static website""" + title, content = fep.content_and_title() + + file_content = ( + frontmatter_for_fep(fep) + + title + + "\n\n" + + format_metatable(fep) + + "\n\n" + + transform_content(content) + ) + + return file_content + + +def make_page_for_fep(fep: FepFile): + """Copies relevant files for the FEP and adjusts index.md""" + slug = fep.fep + + base_path = f"scripts/docs/fep/{slug}/" + Path(base_path).mkdir(exist_ok=True, parents=True) + + shutil.rmtree(base_path) + shutil.copytree(f"fep/{slug}/", base_path) + shutil.move(f"{base_path}/fep-{slug}.md", f"{base_path}/index.md") + + with open(f"{base_path}/index.md", "w") as fp: + fp.write(fep_to_site_mkdocs(fep)) diff --git a/scripts/fep_tools/site_overview_page.py b/scripts/fep_tools/site_overview_page.py new file mode 100644 index 0000000..d5d9222 --- /dev/null +++ b/scripts/fep_tools/site_overview_page.py @@ -0,0 +1,39 @@ +from . import index, table_for_feps + +markdown_frontmatter = """--- +hide: +- toc +- navigation +--- +""" + + +def columns_for_status(status: str) -> list[str]: + columns = [ + "title", + "repo_link_image", + "tracking_issue", + "discussions", + "date_received", + ] + if status == "FINAL": + columns.append("date_finalized") + elif status == "WITHDRAWN": + columns.append("date_withdrawn") + return columns + + +def write_overview_file(filename: str, status: str) -> None: + columns = columns_for_status(status) + feps = [x for x in index() if x.status == status] + + table = "".join( + table_for_feps(feps, columns=columns, generate_title=True, format="static") + ) + + title = status[0] + status[1:].lower() + + with open(filename, "w") as f: + f.write(markdown_frontmatter) + f.write(f"\n# {title}\n") + f.write(table) diff --git a/scripts/fep_tools/table.py b/scripts/fep_tools/table.py new file mode 100644 index 0000000..6ace63a --- /dev/null +++ b/scripts/fep_tools/table.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass + +from .fep_file import FepFile + + +repo_base = "https://codeberg.org/fediverse/fep/src/branch/main/" + + +@dataclass +class TableLineBuilder: + fep: FepFile + columns: list[str] + format: str = "readme" + + @property + def parsed(self): + return self.fep.parsed_frontmatter + + @property + def title(self): + if self.format == "readme": + return f"[FEP-{self.fep.fep}: {self.fep.title}](./{self.fep.filename})" + + return f"[FEP-{self.fep.fep}: {self.fep.title}](fep/{self.fep.fep}/index.md)" + + @property + def repo_link_image(self): + return f"[codeberg]({repo_base}{self.fep.filename})" + + @property + def tracking_issue(self): + url = self.parsed["trackingIssue"] + url_number = url.split("/")[-1] + return f"[#{url_number}]({url})" + + @property + def status(self): + status = self.parsed["status"] + return f"`{status}`" + + @property + def date_received(self): + return self.parsed.get("dateReceived", "-") + + @property + def date_finalized(self): + return self.parsed.get("dateFinalized", "-") + + @property + def date_withdrawn(self): + return self.parsed.get("dateWithdrawn", "-") + + @property + def date_final(self): + if "dateFinalized" in self.parsed: + return self.parsed["dateFinalized"] + if "dateWithdrawn" in self.parsed: + return self.parsed["dateWithdrawn"] + return "-" + + @property + def discussions(self): + discussions = self.parsed["discussionsTo"] + + if discussions == self.parsed["trackingIssue"]: + return "-" + + return f"[Discussions]({discussions})" + + @property + def repository(self) -> str: + url = f"{repo_base}fep/{self.fep.fep}/fep-{self.fep.fep}.md" + return f"[codeberg]({url})" + + def get_attribute(self, column: str): + try: + return self.__getattribute__(column) + except AttributeError: + return self.parsed.get(column, "-") + + @property + def line(self) -> str: + values = [self.get_attribute(column) for column in self.columns] + middle = " | ".join(values) + return f"| {middle} |\n" + + +@dataclass +class TableTitle: + columns: list[str] + + column_to_title = { + "title": "Title", + "status": "Status", + "tracking_issue": "Tracking issue", + "discussions": "Discussions", + "date_received": "Received", + "date_final": "Finalized / Withdrawn", + "date_finalized": "Finalized", + "date_withdrawn": "Withdrawn", + } + + @property + def title_line(self): + center = " | ".join( + [self.column_to_title.get(column, "") for column in self.columns] + ) + + return f"| {center} |\n" + + @property + def second_line(self): + center = " | ".join(["---"] * len(self.columns)) + return f"| {center} |\n" + + @property + def result(self) -> list[str]: + return [self.title_line, self.second_line] + + +def table_for_feps( + feps: list[FepFile], + columns: list[str] = [ + "title", + "status", + "tracking_issue", + "date_received", + # Finalized / Withdrawn + "date_final", + ], + generate_title: bool = False, + format: str = "readme", +): + lines = [TableLineBuilder(fep, columns, format=format).line for fep in feps] + if generate_title: + return TableTitle(columns).result + lines + + return lines diff --git a/scripts/fep_tools/test_fep_file.py b/scripts/fep_tools/test_fep_file.py new file mode 100644 index 0000000..7214fd7 --- /dev/null +++ b/scripts/fep_tools/test_fep_file.py @@ -0,0 +1,13 @@ +from . import fep_file + + +def test_fep_file(): + file = fep_file.FepFile("a4ed") + + assert file.status == "FINAL" + + +def test_implementation_count(): + file = fep_file.FepFile("8b32") + + assert file.implementation_count > 2 diff --git a/scripts/tools/test_feps.py b/scripts/fep_tools/test_feps.py similarity index 88% rename from scripts/tools/test_feps.py rename to scripts/fep_tools/test_feps.py index 5fb15be..4e447ed 100644 --- a/scripts/tools/test_feps.py +++ b/scripts/fep_tools/test_feps.py @@ -3,7 +3,7 @@ import pytest from urllib.parse import urlparse -from scripts.tools import get_fep_ids, FepFile, title_to_slug +from scripts.fep_tools import get_fep_ids, FepFile, title_to_slug @pytest.mark.parametrize("fep", get_fep_ids()) @@ -20,9 +20,9 @@ def test_fep_front_matter(fep): discussions_to = parsed_frontmatter["discussionsTo"] - assert not urlparse(discussions_to).netloc.endswith( - ".example" - ), "Update discussionsTo to a valid URL for a discussion topic" + assert not urlparse(discussions_to).netloc.endswith(".example"), ( + "Update discussionsTo to a valid URL for a discussion topic" + ) if parsed_frontmatter["status"] == "FINAL": assert "dateFinalized" in parsed_frontmatter @@ -36,6 +36,7 @@ def test_fep_front_matter(fep): if "type" in parsed_frontmatter: assert parsed_frontmatter["type"] in ["informational", "implementation"] + @pytest.mark.parametrize("fep", get_fep_ids()) def test_fep_content(fep): fep_file = FepFile(fep) diff --git a/scripts/tools/test_issue.py b/scripts/fep_tools/test_issue.py similarity index 100% rename from scripts/tools/test_issue.py rename to scripts/fep_tools/test_issue.py diff --git a/scripts/fep_tools/test_json.py b/scripts/fep_tools/test_json.py new file mode 100644 index 0000000..b564dfc --- /dev/null +++ b/scripts/fep_tools/test_json.py @@ -0,0 +1,12 @@ +import json +from . import data_for_json_file + + +def test_data_for_json_file(): + result = data_for_json_file() + + assert isinstance(result, list) + + dumped = json.dumps(result) + + assert isinstance(dumped, str) diff --git a/scripts/tools/test_readme.py b/scripts/fep_tools/test_readme.py similarity index 62% rename from scripts/tools/test_readme.py rename to scripts/fep_tools/test_readme.py index 0c79359..7bb511c 100644 --- a/scripts/tools/test_readme.py +++ b/scripts/fep_tools/test_readme.py @@ -1,6 +1,6 @@ import pytest -from scripts.tools import Readme +from scripts.fep_tools import Readme @pytest.mark.skip("Only is correct for main branch and not pull requests") @@ -11,3 +11,9 @@ def test_readme(): expected = Readme().content assert lines == expected + + +def test_readme_can_be_created(): + content = Readme().content + + assert isinstance(content, list) diff --git a/scripts/fep_tools/test_site_fep_page.py b/scripts/fep_tools/test_site_fep_page.py new file mode 100644 index 0000000..ed6bcf1 --- /dev/null +++ b/scripts/fep_tools/test_site_fep_page.py @@ -0,0 +1,71 @@ +import pytest +from . import fep_file +from .site_fep_page import format_metatable, clean_markdown, transform_content + + +def skip_due_to_no_nh3_markdown(): + try: + import nh3 # type: ignore # noqa + import markdown # type: ignore # noqa + except Exception: + return True + return False + + +def test_format_metatable(): + file = fep_file.FepFile("a4ed") + + table = format_metatable(file) + + assert ( + table + == """ +| Authors | Status | Date received | Date final | Tracking issue | Discussions | Repository | +| --- | --- | --- | --- | --- | --- | --- | +| pukkamustard | `FINAL` | 2020-10-16 | 2021-01-18 | [#201](https://codeberg.org/fediverse/fep/issues/201) | - | [codeberg](https://codeberg.org/fediverse/fep/src/branch/main/fep/a4ed/fep-a4ed.md) | + +""" + ) + + +@pytest.mark.skipif( + skip_due_to_no_nh3_markdown(), reason="nh3 and markdown not installed" +) +@pytest.mark.parametrize( + "text,expected", + [ + ("[link](http://test.example)", "link"), + ("[link][link]", "link"), + ("[a][a] [b][b]", "a b"), + ("a:b", "a b"), + ], +) +def test_clean_markdown(text: str, expected: str): + result = clean_markdown(text) + assert result == expected + + +@pytest.mark.skipif( + skip_due_to_no_nh3_markdown(), reason="nh3 and markdown not installed" +) +@pytest.mark.parametrize( + "unchanged", + [ + "[link](http://remote.tests)", + "[fep-f1d5](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)", + "[fep-f1d5]: https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md", + ], +) +def test_content_unchanged(unchanged: str): + assert transform_content(unchanged) == unchanged + + +@pytest.mark.skipif( + skip_due_to_no_nh3_markdown(), reason="nh3 and markdown not installed" +) +def test_content(): + changed = "[link](../4adb/fep-4adb.md)" + assert transform_content(changed) == "[link](../4adb/index.md)" + + changed = "[FEP-4adb]: ../4adb/fep-4adb.md" + assert transform_content(changed) == "[FEP-4adb]: ../4adb/index.md" diff --git a/scripts/fep_tools/test_table.py b/scripts/fep_tools/test_table.py new file mode 100644 index 0000000..c0e2377 --- /dev/null +++ b/scripts/fep_tools/test_table.py @@ -0,0 +1,33 @@ +from .fep_file import FepFile + +from .table import table_for_feps, TableTitle + + +def test_table_for_feps(): + fep = FepFile("a4ed") + result = table_for_feps([fep]) + + assert len(result) == 1 + line = result[0] + + assert ( + line + == "| [FEP-a4ed: The Fediverse Enhancement Proposal Process](./fep/a4ed/fep-a4ed.md) | `FINAL` | [#201](https://codeberg.org/fediverse/fep/issues/201) | 2020-10-16 | 2021-01-18 |\n" + ) + + +def test_table_title(): + columns = [ + "title", + "tracking_issue", + "discussions", + "date_received", + "date_final", + ] + + result = TableTitle(columns).result + + assert result == [ + "| Title | Tracking issue | Discussions | Received | Finalized / Withdrawn |\n", + "| --- | --- | --- | --- | --- |\n", + ] diff --git a/scripts/impl_count.py b/scripts/impl_count.py index 8788274..a395482 100644 --- a/scripts/impl_count.py +++ b/scripts/impl_count.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -from tools import index +from fep_tools import index result = [] print("| Title | Status | Count |") @@ -9,6 +9,8 @@ for fep in index(): slug = fep.parsed_frontmatter["slug"] status = fep.parsed_frontmatter["status"] typ = fep.parsed_frontmatter.get("type") or "-" - if fep.implementations == 0: + if fep.implementation_count == 0: continue - print(f"| [{fep.title}](https://codeberg.org/fediverse/fep/src/branch/main/fep/{slug}/fep-{slug}.md) | {status} | {fep.implementations} |") + print( + f"| [{fep.title}](https://codeberg.org/fediverse/fep/src/branch/main/fep/{slug}/fep-{slug}.md) | {status} | {fep.implementation_count} |" + ) diff --git a/scripts/mkdocs.yml b/scripts/mkdocs.yml new file mode 100644 index 0000000..a4dc088 --- /dev/null +++ b/scripts/mkdocs.yml @@ -0,0 +1,56 @@ +site_name: Fediverse Enhancement Proposals +site_url: https://helge.codeberg.page/fep +repo_url: https://codeberg.org/fediverse/fep +repo_name: fediverse/fep + +theme: + name: material + logo: assets/logo.png + favicon: assets/logo.png + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.tabs + font: false + +markdown_extensions: + - toc: + permalink: true + - github-callouts + - tables + - attr_list + - md_in_html + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - admonition + - pymdownx.details + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - footnotes + +extra_javascript: + - javascripts/tablesort@5.3.0/tablesort.min.js + - javascripts/tablesort.js +extra_css: + - styles/theme.css +nav: + - index.md + - final.md + - draft.md + - withdrawn.md + +plugins: + - search diff --git a/scripts/new_proposal.py b/scripts/new_proposal.py index 143f87e..5c8e161 100755 --- a/scripts/new_proposal.py +++ b/scripts/new_proposal.py @@ -4,7 +4,7 @@ from argparse import ArgumentParser import os import shutil -from tools import title_to_slug, FepFile +from fep_tools import title_to_slug, FepFile def build_parser(): diff --git a/scripts/pull_request.py b/scripts/pull_request.py index d6da146..fa01775 100644 --- a/scripts/pull_request.py +++ b/scripts/pull_request.py @@ -1,6 +1,6 @@ from argparse import ArgumentParser from datetime import date, timedelta -from tools import FepFile +from fep_tools import FepFile import json diff --git a/scripts/pyproject.toml b/scripts/pyproject.toml new file mode 100644 index 0000000..f6eff7d --- /dev/null +++ b/scripts/pyproject.toml @@ -0,0 +1,19 @@ +[project] +name = "fep" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "mkdocs>=1.6.1", + "mkdocs-material[imaging]>=9.5.40", + "pytest>=8.3.4", + "markdown>=3.7", + "pytest-watcher>=0.4.3", + "markdown-callouts>=0.4.0", + "fep", + "nh3>=0.3.0", +] + +[tool.setuptools] +packages = ["fep_tools"] diff --git a/scripts/backmatter.md b/scripts/snippets/backmatter.md similarity index 100% rename from scripts/backmatter.md rename to scripts/snippets/backmatter.md diff --git a/scripts/snippets/fep_title.md b/scripts/snippets/fep_title.md new file mode 100644 index 0000000..b863435 --- /dev/null +++ b/scripts/snippets/fep_title.md @@ -0,0 +1,4 @@ +## FEPs + +| Title | Status | Tracking issue | `dateReceived` | `dateFinalized` (or `dateWithdrawn`) | +| --- | --- | ----- | ------- | ------ | diff --git a/scripts/frontmatter.md b/scripts/snippets/frontmatter.md similarity index 67% rename from scripts/frontmatter.md rename to scripts/snippets/frontmatter.md index 9a8a5ca..96d29d2 100644 --- a/scripts/frontmatter.md +++ b/scripts/snippets/frontmatter.md @@ -1,9 +1,3 @@ -# Fediverse Enhancement Proposals - - - -This is the Git repository containing Fediverse Enhancment Proposals (FEPs). - A Fediverse Enhancement Proposal (FEP) is a document that provides information to the Fediverse community. The goal of a FEP is to improve interoperability and well-being of diverse services, applications and communities that form the Fediverse. The FEP Process is an initiative of the [SocialHub](https://socialhub.activitypub.rocks) developer community, a liaison of the [W3C Social Web Incubator Community Group](https://www.w3.org/community/SocialCG/). For ongoing and past discussion see the [SocialHub FEP category](https://socialhub.activitypub.rocks/c/standards/fep/54). @@ -23,8 +17,8 @@ $ echo -n "The title of my proposal" | sha256sum | cut -c-4 b3f0 ``` -4. Create a subdirectory of [`fep/`](./fep/) using the identifier you just computed. -5. Copy the FEP template ([fep-xxxx-template.md](./fep-xxxx-template.md)) to this subdirectory and change the filename appropriately. +4. Create a subdirectory of [`fep/`](https://codeberg.org/fediverse/fep/src/branch/main/fep/) using the identifier you just computed. +5. Copy the FEP template ([fep-xxxx-template.md](https://codeberg.org/fediverse/fep/src/branch/main/fep-xxxx-template.md)) to this subdirectory and change the filename appropriately. 6. Use the identifer as the "slug" when filling out the frontmatter. - For example, if your computed identifier was `abcd`, then your file would be located at `fep/abcd/fep-abcd.md` and your frontmatter would include `slug: "abcd"`. @@ -33,7 +27,7 @@ b3f0 8. When you are ready to submit the FEP, change the value of `dateReceived` field in the frontmatter to the current date. 9. Create a discussion topic for your FEP. You can use [ActivityPub category](https://socialhub.activitypub.rocks/c/activitypub/5) on the SocialHub forum. 10. Add `discussionsTo` field containing the URL of the discussion topic to the front matter of your FEP. -11. Create a Pull Request to complete Step 1 of [FEP-a4ed: The Fediverse Enhancement Proposal Process](./fep/a4ed/fep-a4ed.md). Further process is described in FEP-a4ed. +11. Create a Pull Request to complete Step 1 of [FEP-a4ed: The Fediverse Enhancement Proposal Process](https://codeberg.org/fediverse/fep/src/branch/main/fep/a4ed/fep-a4ed.md). Further process is described in FEP-a4ed. Alternatively to steps 3. to 6., you can run @@ -45,15 +39,9 @@ that should create a prefilled template for you. ## Facilitators -The list of FEP's is maintained by the facilitators who are listed in the [FACILITATORS.md](FACILITATORS.md) file. Facilitators are neutral custodians of the FEP process, who merge PR's and create tracking issues. +The list of FEP's is maintained by the facilitators who are listed in the [FACILITATORS.md](https://codeberg.org/fediverse/fep/src/branch/main/FACILITATORS.md) file. Facilitators are neutral custodians of the FEP process, who merge PR's and create tracking issues. ## Contributing Do you have ideas to improve the FEP Process? Post your suggestions to the issue tracker, or on the [SocialHub](https://socialhub.activitypub.rocks) forum. The SocialHub developer community is a "DoOcracy" which means: “pick up any task you want, and then steer it to completion”. Your contributions are most welcome, so delve in and find out how you can help. -## FEPs - - - -| Title | Status | Tracking issue | `dateReceived` | `dateFinalized` (or `dateWithdrawn`) | -| --- | --- | ----- | ------- | ------ | diff --git a/scripts/snippets/frontmatter_git.md b/scripts/snippets/frontmatter_git.md new file mode 100644 index 0000000..454aabc --- /dev/null +++ b/scripts/snippets/frontmatter_git.md @@ -0,0 +1,6 @@ +# Fediverse Enhancement Proposals + + + +This is the Git repository containing Fediverse Enhancment Proposals (FEPs). + diff --git a/scripts/tools/__init__.py b/scripts/tools/__init__.py deleted file mode 100644 index e772147..0000000 --- a/scripts/tools/__init__.py +++ /dev/null @@ -1,64 +0,0 @@ -import glob -import hashlib - -from .fep_file import FepFile - - -def title_to_slug(title): - return hashlib.sha256(title.encode("utf-8")).hexdigest()[:4] - - -def get_fep_ids(): - for fep in glob.glob("fep/*"): - yield fep.removeprefix("fep/") - - -def build_url_link(url): - url_number = url.split("/")[-1] - return f"[#{url_number}]({url})" - - -def index(): - fep_files = [FepFile(fep) for fep in get_fep_ids()] - fep_files = sorted( - fep_files, - key=lambda x: (x.parsed_frontmatter["dateReceived"], x.parsed_frontmatter["slug"]), - ) - return fep_files - - -class Readme: - @property - def content(self): - return self.frontmatter + self.table + self.backmatter - - @property - def frontmatter(self): - with open("scripts/frontmatter.md") as f: - return f.readlines() - - @property - def backmatter(self): - with open("scripts/backmatter.md") as f: - return f.readlines() - - @property - def table(self): - result = [] - - for fep in index(): - link = f"[FEP-{fep.fep}: {fep.title}](./{fep.filename})" - parsed = fep.parsed_frontmatter - - tracking_issue = build_url_link(parsed["trackingIssue"]) - - if "dateFinalized" in parsed: - date_final = parsed["dateFinalized"] - elif "dateWithdrawn" in parsed: - date_final = parsed["dateWithdrawn"] - else: - date_final = "-" - result.append( - f"""| {link} | `{parsed["status"]}` | {tracking_issue} | {parsed["dateReceived"]} | {date_final} |\n""" - ) - return result diff --git a/scripts/update_issues.py b/scripts/update_issues.py index a79013c..3f11d88 100755 --- a/scripts/update_issues.py +++ b/scripts/update_issues.py @@ -1,7 +1,7 @@ #!/usr/bin/env python -from tools import get_fep_ids, FepFile -from tools.issue import create_issue +from fep_tools import get_fep_ids, FepFile +from fep_tools.issue import create_issue import os @@ -9,10 +9,23 @@ owner = os.environ.get("CI_REPO_OWNER") repo = os.environ.get("CI_REPO_NAME") token = os.environ.get("CODEBERG_API_TOKEN") +if not isinstance(owner, str): + print("Please set the owner of the repostory at CI_REPO_OWNER") + exit(1) + +if not isinstance(repo, str): + print("Please set the repository at CI_REPO_NAME") + exit(1) + +if not isinstance(token, str): + print("Please provide a codeberg api token") + exit(1) + + for slug in get_fep_ids(): fep_file = FepFile(slug) tracking_issue = fep_file.parsed_frontmatter.get("trackingIssue") if not tracking_issue: print(slug, tracking_issue) - create_issue(owner, repo, token, slug) \ No newline at end of file + create_issue(owner, repo, token, slug)