fmind 0.2.0__tar.gz → 2.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fmind-0.2.0 → fmind-2.0.0}/PKG-INFO +16 -7
- {fmind-0.2.0 → fmind-2.0.0}/README.md +14 -5
- {fmind-0.2.0 → fmind-2.0.0}/pyproject.toml +3 -3
- {fmind-0.2.0 → fmind-2.0.0}/pyproject.toml.orig +3 -3
- {fmind-0.2.0 → fmind-2.0.0}/src/fmind/api.py +5 -4
- {fmind-0.2.0 → fmind-2.0.0}/src/fmind/cli.py +85 -22
- fmind-2.0.0/src/fmind/render.py +254 -0
- fmind-0.2.0/src/fmind/render.py +0 -254
- {fmind-0.2.0 → fmind-2.0.0}/src/fmind/__init__.py +0 -0
- {fmind-0.2.0 → fmind-2.0.0}/src/fmind/__main__.py +0 -0
- {fmind-0.2.0 → fmind-2.0.0}/src/fmind/articles.py +0 -0
- {fmind-0.2.0 → fmind-2.0.0}/src/fmind/mcp.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fmind
|
|
3
|
-
Version:
|
|
3
|
+
Version: 2.0.0
|
|
4
4
|
Summary: Read Médéric Hurier's (Fmind) portfolio from the terminal.
|
|
5
5
|
Keywords: portfolio,cli,fmind,resume,articles
|
|
6
6
|
Author: Médéric Hurier
|
|
@@ -10,7 +10,7 @@ Classifier: Environment :: Console
|
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
12
|
Classifier: Topic :: Utilities
|
|
13
|
-
Requires-Dist: typer>=0.
|
|
13
|
+
Requires-Dist: typer>=0.26
|
|
14
14
|
Requires-Dist: rich>=13.9
|
|
15
15
|
Requires-Dist: httpx2>=2.12,<3 ; extra == 'mcp'
|
|
16
16
|
Requires-Dist: mcp>=2.2,<3 ; extra == 'mcp'
|
|
@@ -42,6 +42,7 @@ Every command fetches from the website and asks HTTP caches to revalidate. There
|
|
|
42
42
|
|
|
43
43
|
| Command | Shows |
|
|
44
44
|
| ------------------------------------------- | ------------------------------------------------- |
|
|
45
|
+
| `fmind` | The `whoami` card and a pointer to `--help` |
|
|
45
46
|
| `fmind whoami` | Name, featured experience, availability, contact |
|
|
46
47
|
| `fmind about` | The biography |
|
|
47
48
|
| `fmind skills` | Core expertise, as published on the site |
|
|
@@ -58,20 +59,22 @@ Every command fetches from the website and asks HTTP caches to revalidate. There
|
|
|
58
59
|
|
|
59
60
|
The commands group sections of the website's profile API. Their names stay stable when the website's navigation or visual design changes.
|
|
60
61
|
|
|
61
|
-
`--limit` bounds
|
|
62
|
+
`--limit` bounds articles and search results, and each project list (repositories and video series); `--json` prints a command's data instead of prose; `--raw` prints an article's Markdown source. Search also accepts `--tag`. Use `--json` and `jq` for other filtering.
|
|
62
63
|
|
|
63
64
|
`--json` works before or after the command: both `fmind --json experiences` and `fmind experiences --json` print the raw website section. Global `--color` / `--no-color` and `--version` go before the command. Colour is detected automatically, including `NO_COLOR` and redirected output. Use `fmind --help` or `fmind <command> --help` for options.
|
|
64
65
|
|
|
66
|
+
Output uses your terminal's default text colour and blue accent. Community roles and credentials use stacked entries with their published links, so they remain readable in narrow terminals. Links are never folded, so they stay copyable, and terminals that support OSC 8 hyperlinks make them clickable. Redirected output carries no trailing whitespace.
|
|
67
|
+
|
|
65
68
|
### Reading
|
|
66
69
|
|
|
67
|
-
`fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags.
|
|
70
|
+
`fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags, case-insensitively; an unknown tag fails and lists the published ones.
|
|
68
71
|
|
|
69
72
|
```bash
|
|
70
73
|
fmind search "agent security"
|
|
71
74
|
fmind search --tag MLOps --limit 3
|
|
72
75
|
```
|
|
73
76
|
|
|
74
|
-
`fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. Use `--raw` for the Markdown source.
|
|
77
|
+
`fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. In an interactive terminal it pages through `$PAGER` (default `less`, with `LESS=FRX` unless you set `LESS`), so short articles print directly; `PAGER=cat` disables paging. Use `--raw` for the Markdown source.
|
|
75
78
|
|
|
76
79
|
```bash
|
|
77
80
|
slug="$(fmind articles --limit 1 --json | jq -r '.[0].slug')"
|
|
@@ -86,9 +89,15 @@ fmind read "$slug" --raw | glow -
|
|
|
86
89
|
```bash
|
|
87
90
|
fmind articles --limit 20 --json | jq -r '.[] | "\(.date[:10]) \(.title)"'
|
|
88
91
|
fmind search agent --json | jq -r '.[].url'
|
|
89
|
-
fmind certifications --json | jq -r '.[] | select(.active) | .title'
|
|
92
|
+
fmind certifications --json | jq -r '.certifications[] | select(.active) | .title'
|
|
90
93
|
```
|
|
91
94
|
|
|
95
|
+
`whoami --json` includes `metadata`, `experience` (a list containing the featured engagement, or an empty list), and `services`. `certifications --json` includes `certifications`, `thesis`, and `specializations`. `projects --json` includes `open_source` and `youtube_series`, each keeping the website's fields and bounded by `--limit`.
|
|
96
|
+
|
|
97
|
+
**Migration to 2.0.0:** `projects --json` now returns an object instead of one mixed array; change filters from `.[]` to `.open_source[]` or `.youtube_series[]`. `--limit` now bounds each list, so video series are no longer crowded out by repositories.
|
|
98
|
+
|
|
99
|
+
**Migration to 1.0.0:** `certifications --json` now returns an object instead of an array; change credential filters from `.[]` to `.certifications[]`. The `experience` field in `whoami --json` is additive. Update existing scripts before upgrading from 0.2.0.
|
|
100
|
+
|
|
92
101
|
## Install
|
|
93
102
|
|
|
94
103
|
```bash
|
|
@@ -145,7 +154,7 @@ mise run smoke # exercise every command against the live site
|
|
|
145
154
|
mise run all # format, check, test, build — the gate CI runs
|
|
146
155
|
```
|
|
147
156
|
|
|
148
|
-
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python
|
|
157
|
+
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python, tests Python 3.11–3.13 for compatibility, and tests the declared dependency floors with `--resolution lowest-direct` on Python 3.11.
|
|
149
158
|
|
|
150
159
|
A release tag must match the version in `pyproject.toml` (for example, `v0.1.0`). CD runs the same full gate for that tag and checks the live commands before publishing through PyPI Trusted Publishing. The live smoke check is a release check, not a pull-request gate.
|
|
151
160
|
|
|
@@ -18,6 +18,7 @@ Every command fetches from the website and asks HTTP caches to revalidate. There
|
|
|
18
18
|
|
|
19
19
|
| Command | Shows |
|
|
20
20
|
| ------------------------------------------- | ------------------------------------------------- |
|
|
21
|
+
| `fmind` | The `whoami` card and a pointer to `--help` |
|
|
21
22
|
| `fmind whoami` | Name, featured experience, availability, contact |
|
|
22
23
|
| `fmind about` | The biography |
|
|
23
24
|
| `fmind skills` | Core expertise, as published on the site |
|
|
@@ -34,20 +35,22 @@ Every command fetches from the website and asks HTTP caches to revalidate. There
|
|
|
34
35
|
|
|
35
36
|
The commands group sections of the website's profile API. Their names stay stable when the website's navigation or visual design changes.
|
|
36
37
|
|
|
37
|
-
`--limit` bounds
|
|
38
|
+
`--limit` bounds articles and search results, and each project list (repositories and video series); `--json` prints a command's data instead of prose; `--raw` prints an article's Markdown source. Search also accepts `--tag`. Use `--json` and `jq` for other filtering.
|
|
38
39
|
|
|
39
40
|
`--json` works before or after the command: both `fmind --json experiences` and `fmind experiences --json` print the raw website section. Global `--color` / `--no-color` and `--version` go before the command. Colour is detected automatically, including `NO_COLOR` and redirected output. Use `fmind --help` or `fmind <command> --help` for options.
|
|
40
41
|
|
|
42
|
+
Output uses your terminal's default text colour and blue accent. Community roles and credentials use stacked entries with their published links, so they remain readable in narrow terminals. Links are never folded, so they stay copyable, and terminals that support OSC 8 hyperlinks make them clickable. Redirected output carries no trailing whitespace.
|
|
43
|
+
|
|
41
44
|
### Reading
|
|
42
45
|
|
|
43
|
-
`fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags.
|
|
46
|
+
`fmind search` matches titles, summaries, tags and slugs; terms are ANDed, so each extra word narrows the result. `--tag` restricts to one of the site's own tags, case-insensitively; an unknown tag fails and lists the published ones.
|
|
44
47
|
|
|
45
48
|
```bash
|
|
46
49
|
fmind search "agent security"
|
|
47
50
|
fmind search --tag MLOps --limit 3
|
|
48
51
|
```
|
|
49
52
|
|
|
50
|
-
`fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. Use `--raw` for the Markdown source.
|
|
53
|
+
`fmind read` takes a slug, or enough of one to be unambiguous, and prints the article. In an interactive terminal it pages through `$PAGER` (default `less`, with `LESS=FRX` unless you set `LESS`), so short articles print directly; `PAGER=cat` disables paging. Use `--raw` for the Markdown source.
|
|
51
54
|
|
|
52
55
|
```bash
|
|
53
56
|
slug="$(fmind articles --limit 1 --json | jq -r '.[0].slug')"
|
|
@@ -62,9 +65,15 @@ fmind read "$slug" --raw | glow -
|
|
|
62
65
|
```bash
|
|
63
66
|
fmind articles --limit 20 --json | jq -r '.[] | "\(.date[:10]) \(.title)"'
|
|
64
67
|
fmind search agent --json | jq -r '.[].url'
|
|
65
|
-
fmind certifications --json | jq -r '.[] | select(.active) | .title'
|
|
68
|
+
fmind certifications --json | jq -r '.certifications[] | select(.active) | .title'
|
|
66
69
|
```
|
|
67
70
|
|
|
71
|
+
`whoami --json` includes `metadata`, `experience` (a list containing the featured engagement, or an empty list), and `services`. `certifications --json` includes `certifications`, `thesis`, and `specializations`. `projects --json` includes `open_source` and `youtube_series`, each keeping the website's fields and bounded by `--limit`.
|
|
72
|
+
|
|
73
|
+
**Migration to 2.0.0:** `projects --json` now returns an object instead of one mixed array; change filters from `.[]` to `.open_source[]` or `.youtube_series[]`. `--limit` now bounds each list, so video series are no longer crowded out by repositories.
|
|
74
|
+
|
|
75
|
+
**Migration to 1.0.0:** `certifications --json` now returns an object instead of an array; change credential filters from `.[]` to `.certifications[]`. The `experience` field in `whoami --json` is additive. Update existing scripts before upgrading from 0.2.0.
|
|
76
|
+
|
|
68
77
|
## Install
|
|
69
78
|
|
|
70
79
|
```bash
|
|
@@ -121,7 +130,7 @@ mise run smoke # exercise every command against the live site
|
|
|
121
130
|
mise run all # format, check, test, build — the gate CI runs
|
|
122
131
|
```
|
|
123
132
|
|
|
124
|
-
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python
|
|
133
|
+
The pre-commit hook runs checks without changing or staging files; run `mise run format` before committing. The pre-push hook runs the offline tests. CI runs the full gate on the pinned Python, tests Python 3.11–3.13 for compatibility, and tests the declared dependency floors with `--resolution lowest-direct` on Python 3.11.
|
|
125
134
|
|
|
126
135
|
A release tag must match the version in `pyproject.toml` (for example, `v0.1.0`). CD runs the same full gate for that tag and checks the live commands before publishing through PyPI Trusted Publishing. The live smoke check is a release check, not a pull-request gate.
|
|
127
136
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fmind"
|
|
3
|
-
version = "
|
|
3
|
+
version = "2.0.0"
|
|
4
4
|
description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -19,7 +19,7 @@ classifiers = [
|
|
|
19
19
|
"Topic :: Utilities",
|
|
20
20
|
]
|
|
21
21
|
dependencies = [
|
|
22
|
-
"typer>=0.
|
|
22
|
+
"typer>=0.26",
|
|
23
23
|
"rich>=13.9",
|
|
24
24
|
]
|
|
25
25
|
|
|
@@ -51,7 +51,7 @@ dev = [
|
|
|
51
51
|
]
|
|
52
52
|
|
|
53
53
|
[build-system]
|
|
54
|
-
requires = ["uv_build>=0.12.
|
|
54
|
+
requires = ["uv_build>=0.12.17,<0.13"]
|
|
55
55
|
build-backend = "uv_build"
|
|
56
56
|
|
|
57
57
|
[tool.uv.build-backend]
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# https://packaging.python.org/en/latest/specifications/pyproject-toml/
|
|
2
2
|
[project]
|
|
3
3
|
name = "fmind"
|
|
4
|
-
version = "
|
|
4
|
+
version = "2.0.0"
|
|
5
5
|
description = "Read Médéric Hurier's (Fmind) portfolio from the terminal."
|
|
6
6
|
readme = "README.md"
|
|
7
7
|
license = "MIT"
|
|
@@ -14,7 +14,7 @@ classifiers = [
|
|
|
14
14
|
"Programming Language :: Python :: 3 :: Only",
|
|
15
15
|
"Topic :: Utilities",
|
|
16
16
|
]
|
|
17
|
-
dependencies = ["typer>=0.
|
|
17
|
+
dependencies = ["typer>=0.26", "rich>=13.9"]
|
|
18
18
|
|
|
19
19
|
[project.urls]
|
|
20
20
|
Homepage = "https://www.fmind.dev/"
|
|
@@ -40,7 +40,7 @@ dev = [
|
|
|
40
40
|
]
|
|
41
41
|
|
|
42
42
|
[build-system]
|
|
43
|
-
requires = ["uv_build>=0.12.
|
|
43
|
+
requires = ["uv_build>=0.12.17,<0.13"]
|
|
44
44
|
build-backend = "uv_build"
|
|
45
45
|
|
|
46
46
|
[tool.uv.build-backend]
|
|
@@ -29,16 +29,17 @@ PROFILE_SHAPE = {
|
|
|
29
29
|
"biography": [str],
|
|
30
30
|
"expertise": [{"title": str, "description": str}],
|
|
31
31
|
"experience": [{"company": str, "title": str, "description": str, "tags": [str]}],
|
|
32
|
-
"leadership": [{"organization": str, "role": str, "description": str}],
|
|
33
|
-
"certifications": [{"title": str, "issuer": str, "active": bool}],
|
|
34
|
-
"specializations": [{"title": str, "issuer_details": str}],
|
|
32
|
+
"leadership": [{"organization": str, "role": str, "description": str, "url": str}],
|
|
33
|
+
"certifications": [{"title": str, "issuer": str, "active": bool, "url": str}],
|
|
34
|
+
"specializations": [{"title": str, "issuer_details": str, "url": str}],
|
|
35
35
|
"thesis": {"title": str, "institution_details": str, "description": str, "url": str},
|
|
36
36
|
"papers": [{"title": str, "venue": str, "url": str}],
|
|
37
37
|
"open_source": [{"title": str, "description": str, "href": str}],
|
|
38
38
|
"youtube_series": [{"title": str, "description": str, "url": str}],
|
|
39
39
|
"site_pages": [{"title": str, "description": str, "audience": str, "url": str}],
|
|
40
40
|
"articles": [{"title": str, "description": str, "slug": str, "date": str, "tags": [str], "reading_minutes": int}],
|
|
41
|
-
"services": [{"title": str, "description": str, "badge": str, "
|
|
41
|
+
"services": [{"title": str, "description": str, "badge": str, "cta_url": str}],
|
|
42
|
+
"tags": [{"name": str}],
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
|
|
@@ -4,11 +4,16 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
7
11
|
import sys
|
|
8
12
|
from typing import Annotated, Any
|
|
9
13
|
|
|
10
14
|
import typer
|
|
11
|
-
from rich.console import RenderableType
|
|
15
|
+
from rich.console import Console, RenderableType
|
|
16
|
+
from rich.pager import Pager
|
|
12
17
|
|
|
13
18
|
from fmind import __version__, articles, render
|
|
14
19
|
from fmind.api import FmindError, load_article, load_profile
|
|
@@ -16,14 +21,14 @@ from fmind.api import FmindError, load_article, load_profile
|
|
|
16
21
|
app = typer.Typer(
|
|
17
22
|
name="fmind",
|
|
18
23
|
help="Read Médéric Hurier's (Fmind) portfolio from the terminal. Every command renders the live "
|
|
19
|
-
"profile published at https://www.fmind.dev/api/profile.
|
|
24
|
+
"profile published at https://www.fmind.dev/api/profile. Without a command, show the whoami card. "
|
|
25
|
+
"Use mcp for the stdio MCP bridge.",
|
|
20
26
|
add_completion=False,
|
|
21
|
-
no_args_is_help=True,
|
|
22
27
|
rich_markup_mode="rich",
|
|
23
28
|
pretty_exceptions_enable=False,
|
|
24
29
|
)
|
|
25
30
|
|
|
26
|
-
JsonOption = Annotated[bool, typer.Option("--json", help="Print the
|
|
31
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Print the command's data as JSON instead of prose.")]
|
|
27
32
|
# One option name for "how many", on every command that prints a list.
|
|
28
33
|
LimitOption = Annotated[int, typer.Option("--limit", min=1, max=200, help="How many entries to show.")]
|
|
29
34
|
|
|
@@ -34,8 +39,9 @@ def _version(value: bool) -> None:
|
|
|
34
39
|
raise typer.Exit
|
|
35
40
|
|
|
36
41
|
|
|
37
|
-
@app.callback()
|
|
42
|
+
@app.callback(invoke_without_command=True)
|
|
38
43
|
def main(
|
|
44
|
+
ctx: typer.Context,
|
|
39
45
|
as_json: JsonOption = False,
|
|
40
46
|
color: Annotated[
|
|
41
47
|
bool | None, typer.Option("--color/--no-color", help="Force or suppress ANSI colour; auto by default.")
|
|
@@ -43,6 +49,11 @@ def main(
|
|
|
43
49
|
_v: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True, help="Show the version.")] = False,
|
|
44
50
|
) -> None:
|
|
45
51
|
"""Set output options before the command, or use --json on any command."""
|
|
52
|
+
if ctx.invoked_subcommand is None:
|
|
53
|
+
# A bare `fmind` works like a business card; `--help` lists everything else.
|
|
54
|
+
whoami(ctx)
|
|
55
|
+
if not as_json:
|
|
56
|
+
_console(ctx).print("\nfmind --help lists every command.", style="dim")
|
|
46
57
|
|
|
47
58
|
|
|
48
59
|
def _fail(message: str) -> typer.Exit:
|
|
@@ -59,19 +70,57 @@ def _doc() -> dict[str, Any]:
|
|
|
59
70
|
raise _fail(str(error)) from error
|
|
60
71
|
|
|
61
72
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
class _SystemPager(Pager):
|
|
74
|
+
"""Page through the user's pager with colour, quitting at once when the text fits one screen."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, command: list[str]) -> None:
|
|
77
|
+
self.command = command
|
|
78
|
+
|
|
79
|
+
def show(self, content: str) -> None:
|
|
80
|
+
# Like Git: respect an explicit LESS, otherwise keep colours (-R), skip
|
|
81
|
+
# paging for short output (-F), and leave the text on screen (-X).
|
|
82
|
+
environment = {**os.environ, "LESS": os.environ.get("LESS", "FRX")}
|
|
83
|
+
subprocess.run(self.command, input=content.encode(), env=environment, check=False) # noqa: S603
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _pager_command() -> list[str] | None:
|
|
87
|
+
"""Return the pager for an interactive terminal, or None to print directly."""
|
|
88
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()) or os.environ.get("TERM") == "dumb":
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
command = shlex.split(os.environ.get("PAGER", "less"))
|
|
92
|
+
except ValueError:
|
|
93
|
+
return None
|
|
94
|
+
if not command or command[0] == "cat" or shutil.which(command[0]) is None:
|
|
95
|
+
return None
|
|
96
|
+
return command
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _console(ctx: typer.Context) -> Console:
|
|
100
|
+
return render.console(color=ctx.find_root().params["color"])
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _write(text: str) -> None:
|
|
104
|
+
"""Write and flush, so a closed reader (`| head`) fails inside Click's EPIPE handling, not at exit."""
|
|
105
|
+
sys.stdout.write(text)
|
|
106
|
+
sys.stdout.flush()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _emit(ctx: typer.Context, body: RenderableType, payload: Any, *, as_json: bool, page: bool = False) -> None:
|
|
110
|
+
"""Print either the rendered section or its JSON data."""
|
|
66
111
|
if as_json or ctx.find_root().params["as_json"]:
|
|
67
|
-
json.
|
|
68
|
-
sys.stdout.write("\n")
|
|
112
|
+
_write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
|
|
69
113
|
return
|
|
70
|
-
out =
|
|
71
|
-
if
|
|
72
|
-
out.print(render.
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
out = _console(ctx)
|
|
115
|
+
if not out.is_terminal:
|
|
116
|
+
out.print(render.Trimmed(body), crop=False)
|
|
117
|
+
return
|
|
118
|
+
command = _pager_command() if page else None
|
|
119
|
+
if command is None:
|
|
120
|
+
out.print(body, crop=False)
|
|
121
|
+
return
|
|
122
|
+
with out.pager(pager=_SystemPager(command), styles=True):
|
|
123
|
+
out.print(body, crop=False)
|
|
75
124
|
|
|
76
125
|
|
|
77
126
|
@app.command()
|
|
@@ -79,7 +128,10 @@ def whoami(ctx: typer.Context, as_json: JsonOption = False) -> None:
|
|
|
79
128
|
"""Name, featured experience, availability, and contact."""
|
|
80
129
|
doc = _doc()
|
|
81
130
|
_emit(
|
|
82
|
-
ctx,
|
|
131
|
+
ctx,
|
|
132
|
+
render.whoami(doc),
|
|
133
|
+
{"metadata": doc["metadata"], "experience": doc["experience"][:1], "services": doc["services"]},
|
|
134
|
+
as_json=as_json,
|
|
83
135
|
)
|
|
84
136
|
|
|
85
137
|
|
|
@@ -110,7 +162,12 @@ def certifications(ctx: typer.Context, as_json: JsonOption = False) -> None:
|
|
|
110
162
|
doc = _doc()
|
|
111
163
|
# Every credential is listed with its state spelled out, the way the website
|
|
112
164
|
# spells it; `--json` and jq narrow the list better than a flag could.
|
|
113
|
-
_emit(
|
|
165
|
+
_emit(
|
|
166
|
+
ctx,
|
|
167
|
+
render.certifications(doc),
|
|
168
|
+
{"certifications": doc["certifications"], "thesis": doc["thesis"], "specializations": doc["specializations"]},
|
|
169
|
+
as_json=as_json,
|
|
170
|
+
)
|
|
114
171
|
|
|
115
172
|
|
|
116
173
|
@app.command()
|
|
@@ -129,9 +186,10 @@ def papers(ctx: typer.Context, as_json: JsonOption = False) -> None:
|
|
|
129
186
|
|
|
130
187
|
@app.command()
|
|
131
188
|
def projects(ctx: typer.Context, limit: LimitOption = 6, as_json: JsonOption = False) -> None:
|
|
132
|
-
"""Open-source repositories and video series."""
|
|
189
|
+
"""Open-source repositories and video series; --limit bounds each list."""
|
|
133
190
|
doc = _doc()
|
|
134
|
-
|
|
191
|
+
payload = {"open_source": doc["open_source"][:limit], "youtube_series": doc["youtube_series"][:limit]}
|
|
192
|
+
_emit(ctx, render.projects(doc, limit=limit), payload, as_json=as_json)
|
|
135
193
|
|
|
136
194
|
|
|
137
195
|
@app.command()
|
|
@@ -163,6 +221,10 @@ def search(
|
|
|
163
221
|
) -> None:
|
|
164
222
|
"""Find articles by term, newest match first."""
|
|
165
223
|
doc = _doc()
|
|
224
|
+
if tag is not None:
|
|
225
|
+
names = [entry["name"] for entry in doc["tags"]]
|
|
226
|
+
if tag.lower() not in {name.lower() for name in names}:
|
|
227
|
+
raise _fail(f"unknown tag {tag!r}; choose one of: {', '.join(names)}")
|
|
166
228
|
found = articles.search(doc["articles"], query, tag=tag, limit=limit)
|
|
167
229
|
footer = f"{len(found)} shown · {len(doc['articles'])} published · fmind read <slug> opens one"
|
|
168
230
|
_emit(ctx, render.articles(found, footer=footer), found, as_json=as_json)
|
|
@@ -190,9 +252,10 @@ def read(
|
|
|
190
252
|
raise _fail(str(error)) from error
|
|
191
253
|
post = articles.by_slug(doc["articles"], resolved)
|
|
192
254
|
if raw and not (as_json or ctx.find_root().params["as_json"]):
|
|
193
|
-
|
|
255
|
+
_write(markdown)
|
|
194
256
|
return
|
|
195
|
-
|
|
257
|
+
payload = {"slug": resolved, "markdown": markdown, **(post or {})}
|
|
258
|
+
_emit(ctx, render.article_body(markdown), payload, as_json=as_json, page=True)
|
|
196
259
|
|
|
197
260
|
|
|
198
261
|
@app.command()
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Render sections of the portfolio document as terminal output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.console import Console, ConsoleOptions, Group, RenderableType, RenderResult
|
|
9
|
+
from rich.markdown import Markdown
|
|
10
|
+
from rich.padding import Padding
|
|
11
|
+
from rich.segment import Segment
|
|
12
|
+
from rich.style import Style
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
# Use the terminal palette so its light/dark theme owns colour contrast.
|
|
17
|
+
ACCENT = "bold blue"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def console(*, color: bool | None = None) -> Console:
|
|
21
|
+
"""Detect terminal colour and NO_COLOR unless explicitly overridden."""
|
|
22
|
+
return Console(
|
|
23
|
+
force_terminal=True if color is True else None,
|
|
24
|
+
color_system="truecolor" if color is True else "auto",
|
|
25
|
+
no_color=None if color is None else not color,
|
|
26
|
+
highlight=False,
|
|
27
|
+
markup=False,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Trimmed:
|
|
32
|
+
"""Drop the padding Rich adds at line ends, which only shows once output is redirected."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, renderable: RenderableType) -> None:
|
|
35
|
+
self.renderable = renderable
|
|
36
|
+
|
|
37
|
+
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
|
|
38
|
+
for line in Segment.split_lines(console.render(self.renderable, options)):
|
|
39
|
+
yield from _rstrip(line)
|
|
40
|
+
yield Segment.line()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _rstrip(line: list[Segment]) -> Iterable[Segment]:
|
|
44
|
+
"""Remove trailing whitespace from one rendered line, keeping control codes."""
|
|
45
|
+
while line and not line[-1].control and not line[-1].text.rstrip():
|
|
46
|
+
line.pop()
|
|
47
|
+
if line and not line[-1].control:
|
|
48
|
+
text, style, _ = line[-1]
|
|
49
|
+
line[-1] = Segment(text.rstrip(), style)
|
|
50
|
+
return line
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _link(url: str) -> Text:
|
|
54
|
+
"""A published URL on one logical line: folding it would break copying and clicking."""
|
|
55
|
+
# Terminals that support OSC 8 make it clickable; the text stays the literal URL.
|
|
56
|
+
style = Style(underline=True, link=url) if url.startswith(("https://", "http://", "mailto:")) else "underline"
|
|
57
|
+
return Text(url, style=style, no_wrap=True, overflow="ignore")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _entry(*lines: RenderableType) -> Group:
|
|
61
|
+
"""One stacked entry followed by a blank line, without padding that would crop links."""
|
|
62
|
+
return Group(*lines, Text())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rows(pairs: list[tuple[str, RenderableType]]) -> Table:
|
|
66
|
+
"""Lay out aligned key/value rows without visible borders."""
|
|
67
|
+
table = Table.grid(padding=(0, 2))
|
|
68
|
+
table.add_column(style=ACCENT, width=11, overflow="fold")
|
|
69
|
+
table.add_column(overflow="fold")
|
|
70
|
+
for key, value in pairs:
|
|
71
|
+
table.add_row(key, value)
|
|
72
|
+
return table
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def whoami(doc: dict[str, Any]) -> RenderableType:
|
|
76
|
+
"""Identity, featured experience, availability, and contact."""
|
|
77
|
+
meta = doc["metadata"]
|
|
78
|
+
experience = doc.get("experience") or []
|
|
79
|
+
mission = Text("—")
|
|
80
|
+
if experience:
|
|
81
|
+
featured = experience[0]
|
|
82
|
+
mission = Text(f"{featured['company']} — {featured['title']}")
|
|
83
|
+
services = doc.get("services") or []
|
|
84
|
+
status = Text()
|
|
85
|
+
for index, service in enumerate(services):
|
|
86
|
+
if index:
|
|
87
|
+
status.append("\n")
|
|
88
|
+
status.append(service["title"], style="bold")
|
|
89
|
+
status.append(f" — {service['badge']}")
|
|
90
|
+
return _rows(
|
|
91
|
+
[
|
|
92
|
+
("Name", Text(f"{meta['name']} ({meta['alternate_name']})", style="bold")),
|
|
93
|
+
("Role", Text(meta["job_title"])),
|
|
94
|
+
("Headline", Text(meta["headline_primary"])),
|
|
95
|
+
("Experience", mission),
|
|
96
|
+
("Status", status),
|
|
97
|
+
("Contact", _link(meta["email"])),
|
|
98
|
+
("Website", _link(meta["site_url"])),
|
|
99
|
+
]
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def about(doc: dict[str, Any]) -> RenderableType:
|
|
104
|
+
"""The biography paragraphs."""
|
|
105
|
+
return Group(*(Padding(Markdown(p, hyperlinks=False), (0, 0, 1, 0)) for p in doc["biography"]))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def skills(doc: dict[str, Any]) -> RenderableType:
|
|
109
|
+
"""Show expertise without inventing command-line options or local labels."""
|
|
110
|
+
return Group(*(_entry(Text(card["title"], style="bold"), Text(card["description"])) for card in doc["expertise"]))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def experiences(doc: dict[str, Any]) -> RenderableType:
|
|
114
|
+
"""Engagements in the order published by the website."""
|
|
115
|
+
return Group(
|
|
116
|
+
*(
|
|
117
|
+
_entry(
|
|
118
|
+
Text(job["company"].upper(), style=ACCENT),
|
|
119
|
+
Text(job["title"], style="bold"),
|
|
120
|
+
Text(job["description"]),
|
|
121
|
+
Text(" ".join(job["tags"])),
|
|
122
|
+
)
|
|
123
|
+
for job in doc["experience"]
|
|
124
|
+
)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def community(doc: dict[str, Any]) -> RenderableType:
|
|
129
|
+
"""Ambassador and advisory roles, with their published links."""
|
|
130
|
+
return Group(
|
|
131
|
+
*(
|
|
132
|
+
_entry(
|
|
133
|
+
Text(role["organization"], style=ACCENT),
|
|
134
|
+
Text(role["role"], style="bold"),
|
|
135
|
+
Text(role["description"]),
|
|
136
|
+
_link(role["url"]),
|
|
137
|
+
)
|
|
138
|
+
for role in doc["leadership"]
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def certifications(doc: dict[str, Any]) -> RenderableType:
|
|
144
|
+
"""Credentials, doctorate, and specializations with published evidence."""
|
|
145
|
+
blocks: list[RenderableType] = []
|
|
146
|
+
for badge in doc["certifications"]:
|
|
147
|
+
state = "active" if badge["active"] else "past"
|
|
148
|
+
blocks.append(
|
|
149
|
+
_entry(Text(f"[{state}] {badge['title']}", style=ACCENT), Text(badge["issuer"]), _link(badge["url"]))
|
|
150
|
+
)
|
|
151
|
+
thesis = doc["thesis"]
|
|
152
|
+
blocks.append(
|
|
153
|
+
_entry(Text(thesis["title"], style=ACCENT), Text(thesis["institution_details"]), _link(thesis["url"]))
|
|
154
|
+
)
|
|
155
|
+
if doc["specializations"]:
|
|
156
|
+
blocks += [Text("SPECIALIZATIONS", style=ACCENT), Text()]
|
|
157
|
+
blocks += [
|
|
158
|
+
_entry(Text(spec["title"], style="bold"), Text(spec["issuer_details"]), _link(spec["url"]))
|
|
159
|
+
for spec in doc["specializations"]
|
|
160
|
+
]
|
|
161
|
+
return Group(*blocks)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def projects(doc: dict[str, Any], *, limit: int = 6) -> RenderableType:
|
|
165
|
+
"""Open-source repositories, then video series; `limit` bounds each list."""
|
|
166
|
+
count = max(limit, 0)
|
|
167
|
+
blocks: list[RenderableType] = [
|
|
168
|
+
_entry(Text(project["title"], style="bold"), Text(project["description"]), _link(project["href"]))
|
|
169
|
+
for project in doc["open_source"][:count]
|
|
170
|
+
]
|
|
171
|
+
series = doc["youtube_series"][:count]
|
|
172
|
+
if series:
|
|
173
|
+
blocks += [Text("VIDEO SERIES", style=ACCENT), Text()]
|
|
174
|
+
blocks += [
|
|
175
|
+
_entry(Text(video["title"], style="bold"), Text(video["description"]), _link(video["url"])) for video in series
|
|
176
|
+
]
|
|
177
|
+
return Group(*blocks)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def articles(posts: list[dict[str, Any]], *, footer: str = "") -> RenderableType:
|
|
181
|
+
"""A list of articles, one block each, with `fmind read` slugs kept visible."""
|
|
182
|
+
blocks: list[RenderableType] = []
|
|
183
|
+
for post in posts:
|
|
184
|
+
head = Text(post["date"][:10], style=ACCENT)
|
|
185
|
+
head.append(f" {post['reading_minutes']} min")
|
|
186
|
+
head.append(f" {' '.join(post['tags'])}")
|
|
187
|
+
body = Text("fmind read ")
|
|
188
|
+
body.append(post["slug"], style="bold")
|
|
189
|
+
blocks.append(_entry(head, Text(post["title"], style="bold"), body))
|
|
190
|
+
if not blocks:
|
|
191
|
+
return Text("no article matches")
|
|
192
|
+
return Group(*blocks, Text(footer)) if footer else Group(*blocks)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def article_body(markdown: str) -> RenderableType:
|
|
196
|
+
"""One article, rendered from its published Markdown source.
|
|
197
|
+
|
|
198
|
+
The source already opens with the title, summary, date, reading time, tags and
|
|
199
|
+
canonical URL, so nothing is prepended here.
|
|
200
|
+
"""
|
|
201
|
+
return Markdown(markdown, code_theme="ansi_dark", hyperlinks=False)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def papers(doc: dict[str, Any]) -> RenderableType:
|
|
205
|
+
"""The doctorate and the peer-reviewed record behind it."""
|
|
206
|
+
thesis = doc["thesis"]
|
|
207
|
+
parts: list[RenderableType] = [
|
|
208
|
+
_entry(
|
|
209
|
+
Text(thesis["title"], style="bold"),
|
|
210
|
+
Text(thesis["institution_details"]),
|
|
211
|
+
Text(thesis["description"]),
|
|
212
|
+
_link(thesis["url"]),
|
|
213
|
+
)
|
|
214
|
+
]
|
|
215
|
+
for link in thesis.get("links") or []:
|
|
216
|
+
row = Text(f" {link['label']} ", no_wrap=True, overflow="ignore")
|
|
217
|
+
row.append_text(_link(link["url"]))
|
|
218
|
+
parts.append(row)
|
|
219
|
+
if thesis.get("links"):
|
|
220
|
+
parts.append(Text())
|
|
221
|
+
parts += [Text("PUBLICATIONS", style=ACCENT), Text()]
|
|
222
|
+
parts += [
|
|
223
|
+
_entry(Text(paper["title"], style="bold"), Text(paper["venue"]), _link(paper["url"])) for paper in doc["papers"]
|
|
224
|
+
]
|
|
225
|
+
return Group(*parts)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def sites(doc: dict[str, Any]) -> RenderableType:
|
|
229
|
+
"""Interactive tools published alongside the writing."""
|
|
230
|
+
blocks = [
|
|
231
|
+
_entry(
|
|
232
|
+
Text(page["title"], style="bold"),
|
|
233
|
+
Text(page["description"]),
|
|
234
|
+
Text(f"for {page['audience']}"),
|
|
235
|
+
_link(page["url"]),
|
|
236
|
+
)
|
|
237
|
+
for page in doc["site_pages"]
|
|
238
|
+
]
|
|
239
|
+
return Group(*blocks) if blocks else Text("no interactive site published")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def hire(doc: dict[str, Any]) -> RenderableType:
|
|
243
|
+
"""What can be booked right now."""
|
|
244
|
+
return Group(
|
|
245
|
+
*(
|
|
246
|
+
_entry(
|
|
247
|
+
Text(service["title"], style="bold"),
|
|
248
|
+
Text(service["description"]),
|
|
249
|
+
Text(service["badge"], style="bold"),
|
|
250
|
+
_link(service["cta_url"]),
|
|
251
|
+
)
|
|
252
|
+
for service in doc["services"]
|
|
253
|
+
)
|
|
254
|
+
)
|
fmind-0.2.0/src/fmind/render.py
DELETED
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
"""Render sections of the portfolio document as terminal output."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from rich.console import Console, Group, RenderableType
|
|
8
|
-
from rich.markdown import Markdown
|
|
9
|
-
from rich.padding import Padding
|
|
10
|
-
from rich.table import Table
|
|
11
|
-
from rich.text import Text
|
|
12
|
-
|
|
13
|
-
ACCENT = "bold #00ff41"
|
|
14
|
-
HI = "bold #ccffd9"
|
|
15
|
-
DIM = "#57a86e"
|
|
16
|
-
FLAG = "#7dffab"
|
|
17
|
-
ERR = "#ff7a5c"
|
|
18
|
-
|
|
19
|
-
WORDMARK = (
|
|
20
|
-
"█▀▀▀ █▄ ▄█ ▀█▀ █▄ █ █▀▀▄ ",
|
|
21
|
-
"█▄▄ █ ▀ █ █ █ █ █ █ █",
|
|
22
|
-
"█ █ █ █ █ ██ █ █",
|
|
23
|
-
"█ █ █ ▄█▄ █ █ █▄▄▀ ",
|
|
24
|
-
)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def console(*, color: bool | None = None) -> Console:
|
|
28
|
-
"""Detect terminal colour and NO_COLOR unless explicitly overridden."""
|
|
29
|
-
return Console(
|
|
30
|
-
force_terminal=True if color is True else None,
|
|
31
|
-
color_system="truecolor" if color is True else "auto",
|
|
32
|
-
no_color=None if color is None else not color,
|
|
33
|
-
highlight=False,
|
|
34
|
-
markup=False,
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _rows(pairs: list[tuple[str, RenderableType]], key_width: int = 11) -> Table:
|
|
39
|
-
"""Lay out aligned key/value rows without visible borders."""
|
|
40
|
-
table = Table.grid(padding=(0, 2))
|
|
41
|
-
table.add_column(style=ACCENT, width=key_width, overflow="fold")
|
|
42
|
-
table.add_column(overflow="fold")
|
|
43
|
-
for key, value in pairs:
|
|
44
|
-
table.add_row(key, value)
|
|
45
|
-
return table
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def banner(doc: dict[str, Any]) -> RenderableType:
|
|
49
|
-
"""The wordmark beside the name, role and headline."""
|
|
50
|
-
meta = doc["metadata"]
|
|
51
|
-
mark = Text("\n".join(WORDMARK), style=ACCENT)
|
|
52
|
-
identity = Group(
|
|
53
|
-
Text(meta["name"], style=HI),
|
|
54
|
-
Text(meta["job_title"], style="#00ff41"),
|
|
55
|
-
Text(meta["headline_primary"], style=DIM),
|
|
56
|
-
)
|
|
57
|
-
side = Table.grid(padding=(0, 3))
|
|
58
|
-
side.add_column(no_wrap=True)
|
|
59
|
-
side.add_column(overflow="fold")
|
|
60
|
-
side.add_row(mark, identity)
|
|
61
|
-
return side
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def whoami(doc: dict[str, Any]) -> RenderableType:
|
|
65
|
-
"""Identity, featured experience, availability, and contact."""
|
|
66
|
-
meta = doc["metadata"]
|
|
67
|
-
experience = doc.get("experience") or []
|
|
68
|
-
mission = Text("—", style=DIM)
|
|
69
|
-
if experience:
|
|
70
|
-
current = experience[0]
|
|
71
|
-
mission = Text(f"{current['company']} — {current['title']}", style="#74e492")
|
|
72
|
-
services = doc.get("services") or []
|
|
73
|
-
status = Text()
|
|
74
|
-
for index, service in enumerate(services):
|
|
75
|
-
open_now = service.get("badge_type") != "error"
|
|
76
|
-
if index:
|
|
77
|
-
status.append("\n")
|
|
78
|
-
status.append("● ", style="#00ff41" if open_now else ERR)
|
|
79
|
-
status.append(service["title"], style=HI)
|
|
80
|
-
status.append(f" — {service['badge']}", style=DIM)
|
|
81
|
-
return _rows(
|
|
82
|
-
[
|
|
83
|
-
("Name", Text(f"{meta['name']} ({meta['alternate_name']})", style=HI)),
|
|
84
|
-
("Role", Text(meta["job_title"], style="#74e492")),
|
|
85
|
-
("Experience", mission),
|
|
86
|
-
("Status", status),
|
|
87
|
-
("Contact", Text(meta["email"], style=FLAG)),
|
|
88
|
-
("Website", Text(meta["site_url"], style=FLAG)),
|
|
89
|
-
]
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def about(doc: dict[str, Any]) -> RenderableType:
|
|
94
|
-
"""The biography paragraphs."""
|
|
95
|
-
return Group(*(Padding(Markdown(p, hyperlinks=False), (0, 0, 1, 0)) for p in doc["biography"]))
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def skills(doc: dict[str, Any]) -> RenderableType:
|
|
99
|
-
"""Show expertise without inventing command-line options or local labels."""
|
|
100
|
-
return Group(
|
|
101
|
-
*(
|
|
102
|
-
Padding(Group(Text(card["title"], style=HI), Text(card["description"], style=DIM)), (0, 0, 1, 0))
|
|
103
|
-
for card in doc["expertise"]
|
|
104
|
-
)
|
|
105
|
-
)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def experiences(doc: dict[str, Any]) -> RenderableType:
|
|
109
|
-
"""Engagements in the order published by the website."""
|
|
110
|
-
blocks: list[RenderableType] = []
|
|
111
|
-
for job in doc["experience"]:
|
|
112
|
-
head = Text(job["company"].upper(), style=ACCENT)
|
|
113
|
-
body = Group(
|
|
114
|
-
head,
|
|
115
|
-
Text(job["title"], style=HI),
|
|
116
|
-
Text(job["description"], style=DIM),
|
|
117
|
-
Text(" ".join(job["tags"]), style=FLAG),
|
|
118
|
-
)
|
|
119
|
-
blocks.append(Padding(body, (0, 0, 1, 0)))
|
|
120
|
-
return Group(*blocks)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def community(doc: dict[str, Any]) -> RenderableType:
|
|
124
|
-
"""Ambassador and advisory roles."""
|
|
125
|
-
return _rows(
|
|
126
|
-
[
|
|
127
|
-
(r["organization"], Group(Text(r["role"], style=HI), Text(r["description"], style=DIM)))
|
|
128
|
-
for r in doc["leadership"]
|
|
129
|
-
],
|
|
130
|
-
key_width=28,
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def certifications(doc: dict[str, Any]) -> RenderableType:
|
|
135
|
-
"""Credentials, the doctorate that backs them, and the specializations."""
|
|
136
|
-
table = Table.grid(padding=(0, 2))
|
|
137
|
-
table.add_column(width=8, no_wrap=True)
|
|
138
|
-
table.add_column(overflow="fold")
|
|
139
|
-
for badge in doc["certifications"]:
|
|
140
|
-
active = bool(badge["active"])
|
|
141
|
-
# Spell out the published state so colour never carries it alone.
|
|
142
|
-
state = Text("[active]" if active else "[past]", style="#00ff41" if active else DIM)
|
|
143
|
-
body = Text(badge["title"], style=HI if active else "#74e492")
|
|
144
|
-
body.append(f" — {badge['issuer']}", style=DIM)
|
|
145
|
-
table.add_row(state, body)
|
|
146
|
-
degree = Text(doc["thesis"]["title"], style=HI)
|
|
147
|
-
degree.append(f" — {doc['thesis']['institution_details']}", style=DIM)
|
|
148
|
-
specs = Text("\n".join(f" {s['title']} — {s['issuer_details']}" for s in doc["specializations"]), style=DIM)
|
|
149
|
-
return Group(
|
|
150
|
-
table,
|
|
151
|
-
Padding(degree, (1, 0, 0, 0)),
|
|
152
|
-
Padding(Group(Text("SPECIALIZATIONS", style=DIM), specs), (1, 0, 0, 0)),
|
|
153
|
-
)
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def projects(doc: dict[str, Any], *, limit: int = 6) -> RenderableType:
|
|
157
|
-
"""Open-source repositories and video series."""
|
|
158
|
-
items = [(p["title"], p["description"], p["href"]) for p in doc["open_source"]]
|
|
159
|
-
items += [(v["title"], v["description"], v["url"]) for v in doc["youtube_series"]]
|
|
160
|
-
blocks = [
|
|
161
|
-
Padding(Group(Text(title, style=HI), Text(description, style=DIM), Text(url, style=FLAG)), (0, 0, 1, 0))
|
|
162
|
-
for title, description, url in items[: max(limit, 0)]
|
|
163
|
-
]
|
|
164
|
-
return Group(*blocks)
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
def articles(posts: list[dict[str, Any]], *, footer: str = "") -> RenderableType:
|
|
168
|
-
"""A list of articles, one block each, with `fmind read` slugs kept visible."""
|
|
169
|
-
blocks: list[RenderableType] = []
|
|
170
|
-
for post in posts:
|
|
171
|
-
head = Text(post["date"][:10], style=ACCENT)
|
|
172
|
-
head.append(f" {post['reading_minutes']} min", style=DIM)
|
|
173
|
-
head.append(f" {' '.join(post['tags'])}", style=FLAG)
|
|
174
|
-
body = Text("fmind read ", style=DIM)
|
|
175
|
-
body.append(post["slug"], style=FLAG)
|
|
176
|
-
blocks.append(Padding(Group(head, Text(post["title"], style=HI), body), (0, 0, 1, 0)))
|
|
177
|
-
if not blocks:
|
|
178
|
-
return Text("no article matches", style=DIM)
|
|
179
|
-
return Group(*blocks, Text(footer, style=DIM)) if footer else Group(*blocks)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def article_body(markdown: str) -> RenderableType:
|
|
183
|
-
"""One article, rendered from its published Markdown source.
|
|
184
|
-
|
|
185
|
-
The source already opens with the title, summary, date, reading time, tags and
|
|
186
|
-
canonical URL, so nothing is prepended here.
|
|
187
|
-
"""
|
|
188
|
-
return Markdown(markdown, code_theme="ansi_dark", hyperlinks=False)
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
def papers(doc: dict[str, Any]) -> RenderableType:
|
|
192
|
-
"""The doctorate and the peer-reviewed record behind it."""
|
|
193
|
-
thesis = doc["thesis"]
|
|
194
|
-
head = Group(
|
|
195
|
-
Text(thesis["title"], style=HI),
|
|
196
|
-
Text(thesis["institution_details"], style="#74e492"),
|
|
197
|
-
Text(thesis["description"], style=DIM),
|
|
198
|
-
Text(thesis["url"], style=FLAG),
|
|
199
|
-
)
|
|
200
|
-
parts: list[RenderableType] = [Padding(head, (0, 0, 1, 0))]
|
|
201
|
-
for link in thesis.get("links") or []:
|
|
202
|
-
row = Text(" ", style=DIM)
|
|
203
|
-
row.append(link["label"], style="#74e492")
|
|
204
|
-
row.append(f" {link['url']}", style=FLAG)
|
|
205
|
-
parts.append(row)
|
|
206
|
-
if thesis.get("links"):
|
|
207
|
-
parts.append(Text())
|
|
208
|
-
parts.append(Text("PUBLICATIONS", style=ACCENT))
|
|
209
|
-
for paper in doc["papers"]:
|
|
210
|
-
body = Group(
|
|
211
|
-
Text(paper["title"], style=HI),
|
|
212
|
-
Text(paper["venue"], style=DIM),
|
|
213
|
-
Text(paper["url"], style=FLAG),
|
|
214
|
-
)
|
|
215
|
-
parts.append(Padding(body, (1, 0, 0, 0)))
|
|
216
|
-
return Group(*parts)
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def sites(doc: dict[str, Any]) -> RenderableType:
|
|
220
|
-
"""Interactive tools published alongside the writing."""
|
|
221
|
-
blocks = [
|
|
222
|
-
Padding(
|
|
223
|
-
Group(
|
|
224
|
-
Text(page["title"], style=HI),
|
|
225
|
-
Text(page["description"], style=DIM),
|
|
226
|
-
Text(f"for {page['audience']}", style="#74e492"),
|
|
227
|
-
Text(page["url"], style=FLAG),
|
|
228
|
-
),
|
|
229
|
-
(0, 0, 1, 0),
|
|
230
|
-
)
|
|
231
|
-
for page in doc["site_pages"]
|
|
232
|
-
]
|
|
233
|
-
return Group(*blocks) if blocks else Text("no interactive site published", style=DIM)
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def hire(doc: dict[str, Any]) -> RenderableType:
|
|
237
|
-
"""What can be booked right now."""
|
|
238
|
-
blocks: list[RenderableType] = []
|
|
239
|
-
for service in doc["services"]:
|
|
240
|
-
open_now = service.get("badge_type") != "error"
|
|
241
|
-
state = Text("● ", style="#00ff41" if open_now else ERR)
|
|
242
|
-
state.append(service["badge"], style="#00ff41" if open_now else ERR)
|
|
243
|
-
blocks.append(
|
|
244
|
-
Padding(
|
|
245
|
-
Group(
|
|
246
|
-
Text(service["title"], style=HI),
|
|
247
|
-
Text(service["description"], style=DIM),
|
|
248
|
-
state,
|
|
249
|
-
Text(service["cta_url"], style=FLAG),
|
|
250
|
-
),
|
|
251
|
-
(0, 0, 1, 0),
|
|
252
|
-
)
|
|
253
|
-
)
|
|
254
|
-
return Group(*blocks)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|