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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fmind
3
- Version: 0.2.0
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.15
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 projects, articles, and search results; `--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
+ `--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 and tests Python 3.11–3.13 for compatibility.
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 projects, articles, and search results; `--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
+ `--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 and tests Python 3.11–3.13 for compatibility.
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 = "0.2.0"
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.15",
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.10,<0.13"]
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 = "0.2.0"
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.15", "rich>=13.9"]
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.10,<0.13"]
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, "badge_type": str, "cta_url": 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. Use mcp for the stdio MCP bridge.",
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 raw section as JSON instead of prose.")]
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
- def _emit(
63
- ctx: typer.Context, body: RenderableType, payload: Any, *, as_json: bool, banner: dict[str, Any] | None = None
64
- ) -> None:
65
- """Print either the rendered section or its raw JSON."""
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.dump(payload, sys.stdout, ensure_ascii=False, indent=2)
68
- sys.stdout.write("\n")
112
+ _write(json.dumps(payload, ensure_ascii=False, indent=2) + "\n")
69
113
  return
70
- out = render.console(color=ctx.find_root().params["color"])
71
- if banner is not None:
72
- out.print(render.banner(banner))
73
- out.print()
74
- out.print(body)
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, render.whoami(doc), {"metadata": doc["metadata"], "services": doc["services"]}, banner=doc, as_json=as_json
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(ctx, render.certifications(doc), doc["certifications"], as_json=as_json)
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
- _emit(ctx, render.projects(doc, limit=limit), (doc["open_source"] + doc["youtube_series"])[:limit], as_json=as_json)
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
- sys.stdout.write(markdown)
255
+ _write(markdown)
194
256
  return
195
- _emit(ctx, render.article_body(markdown), {"slug": resolved, "markdown": markdown, **(post or {})}, as_json=as_json)
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
+ )
@@ -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