article 0.0.1__tar.gz → 0.1.2__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.
@@ -0,0 +1,17 @@
1
+ root = true
2
+
3
+ [*]
4
+ charset = utf-8
5
+ end_of_line = lf
6
+ insert_final_newline = true
7
+ trim_trailing_whitespace = true
8
+
9
+ [*.{py,toml,yml,yaml}]
10
+ indent_style = space
11
+ indent_size = 4
12
+
13
+ [*.md]
14
+ trim_trailing_whitespace = false
15
+
16
+ [Makefile]
17
+ indent_style = tab
@@ -0,0 +1,35 @@
1
+ # article — configuration & secrets (copy to `.env`; never commit the real .env)
2
+ # Loaded by article.config.Settings via pydantic-settings.
3
+ # Every platform is optional: omit a platform's secrets to skip it.
4
+
5
+ # --- Substack (Phase 1, primary) ---------------------------------------------
6
+ # Public URL of your publication; the canonical SSOT URL is derived from it.
7
+ SUBSTACK_PUBLICATION_URL=https://yourname.substack.com
8
+ # Credentials are only needed when no saved browser session exists yet.
9
+ SUBSTACK_EMAIL=you@example.com
10
+ SUBSTACK_PASSWORD=
11
+ # Where the Playwright storage_state (cookies/session) is persisted/reused.
12
+ SUBSTACK_SESSION_PATH=.article/substack_session.json
13
+
14
+ # --- Medium (Phase 2) --------------------------------------------------------
15
+ # Self-issued integration token: Settings > Security and apps > Integration tokens.
16
+ # NOTE: Medium's API is deprecated but still works with a self-issued token.
17
+ MEDIUM_TOKEN=
18
+ MEDIUM_USER_ID=
19
+
20
+ # --- Dev.to / Forem (Phase 2) ------------------------------------------------
21
+ # Generate at https://dev.to/settings/extensions ("DEV API Keys").
22
+ DEVTO_API_KEY=
23
+
24
+ # --- Hashnode (Phase 2) ------------------------------------------------------
25
+ # Personal Access Token from https://hashnode.com/settings/developer
26
+ HASHNODE_TOKEN=
27
+ # Publication ObjectId (query `publication(host: "...") { id }`).
28
+ HASHNODE_PUBLICATION_ID=
29
+
30
+ # --- Pipeline ----------------------------------------------------------------
31
+ # JSON state store; the canonical_url SSOT lives here, keyed by article slug.
32
+ STATE_PATH=pipeline_state.json
33
+ # Human-like pacing window (seconds) for browser automation.
34
+ MIN_ACTION_DELAY=0.6
35
+ MAX_ACTION_DELAY=2.4
article-0.1.2/PKG-INFO ADDED
@@ -0,0 +1,166 @@
1
+ Metadata-Version: 2.4
2
+ Name: article
3
+ Version: 0.1.2
4
+ Summary: Tools to semi-automate the publication of articles
5
+ Project-URL: Homepage, https://github.com/thorwhalen/article
6
+ Project-URL: Repository, https://github.com/thorwhalen/article
7
+ Project-URL: Documentation, https://thorwhalen.github.io/article
8
+ Author: Thor Whalen
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: automation,canonical-url,dev.to,hashnode,medium,publishing,seo,substack,syndication
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Topic :: Text Processing :: Markup
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: argh
21
+ Requires-Dist: httpx
22
+ Requires-Dist: pydantic-settings>=2
23
+ Requires-Dist: pydantic>=2
24
+ Requires-Dist: python-dotenv
25
+ Provides-Extra: completion
26
+ Requires-Dist: argcomplete; extra == 'completion'
27
+ Provides-Extra: dev
28
+ Requires-Dist: playwright; extra == 'dev'
29
+ Requires-Dist: pytest-cov>=4.0; extra == 'dev'
30
+ Requires-Dist: pytest>=7.0; extra == 'dev'
31
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
32
+ Provides-Extra: docs
33
+ Requires-Dist: sphinx-rtd-theme>=1.0; extra == 'docs'
34
+ Requires-Dist: sphinx>=6.0; extra == 'docs'
35
+ Provides-Extra: substack
36
+ Requires-Dist: playwright; extra == 'substack'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # article
40
+
41
+ Tools to semi-automate the publication of articles.
42
+
43
+ Write an article **once** as a JSON file. Publish it to a **primary** platform
44
+ (Substack), then **syndicate** it to secondary platforms (Medium, Dev.to,
45
+ Hashnode) — each pointing its canonical URL back to the primary, so you get
46
+ cross-platform reach without an SEO duplicate-content penalty.
47
+
48
+ ```bash
49
+ pip install article
50
+ ```
51
+
52
+ ## The single source of truth
53
+
54
+ The Substack public URL captured in phase 1 is the SSOT. It is stored
55
+ (keyed by article slug) and injected as `canonical_url` into every secondary
56
+ platform in phase 2, so each emits `<link rel="canonical" href="…">` pointing
57
+ back at the original.
58
+
59
+ ```
60
+ author.json ──► publish-primary ──► Substack ──► live URL ─┐
61
+ │ stored as canonical_url
62
+ ▼ (pipeline_state.json, by slug)
63
+ syndicate-secondary ──► Medium / Dev.to / Hashnode
64
+ each canonical → Substack URL
65
+ ```
66
+
67
+ The two phases are deliberately separate commands so you can syndicate later
68
+ (e.g. `+3 days`) on your own schedule.
69
+
70
+ ## Two-command flow
71
+
72
+ ```bash
73
+ # 1. Author once, then publish to the primary (records the canonical URL):
74
+ python -m article publish-primary article.json
75
+
76
+ # 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
77
+ python -m article syndicate-secondary article.json
78
+
79
+ # Subset / machine-readable output:
80
+ python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out
81
+ ```
82
+
83
+ After `pip install`, the `article` console script is equivalent
84
+ (`article publish-primary article.json`).
85
+
86
+ ## The article JSON
87
+
88
+ One standardized file is the single source of truth for content **and** which
89
+ platforms to publish to — a platform is published only if it appears under
90
+ `platforms` (see [`examples/article.example.json`](examples/article.example.json)):
91
+
92
+ ```json
93
+ {
94
+ "title": "Designing an SSOT Publishing Pipeline",
95
+ "slug": "ssot-publishing-pipeline",
96
+ "content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
97
+ "tags": ["python", "automation", "seo"],
98
+ "platforms": {
99
+ "substack": { "publish_as_draft": false },
100
+ "medium": { "publish_as_draft": true },
101
+ "dev_to": { "publish_as_draft": true },
102
+ "hashnode": { "publish_as_draft": true }
103
+ }
104
+ }
105
+ ```
106
+
107
+ ## Configuration
108
+
109
+ Secrets and settings load from `.env` (see
110
+ [`.env.example`](.env.example)) — copy it and fill in what you use:
111
+
112
+ ```bash
113
+ cp .env.example .env
114
+ ```
115
+
116
+ Every platform is optional; omit a platform's secrets (or its `platforms`
117
+ entry) to skip it.
118
+
119
+ ## Using it from Python
120
+
121
+ The CLI is a thin wrapper over the same functions, so everything is callable
122
+ programmatically:
123
+
124
+ ```python
125
+ import asyncio
126
+ from article import load_article, publish_primary, syndicate_secondary
127
+
128
+ art = load_article("article.json")
129
+ asyncio.run(publish_primary(art)) # phase 1 → records canonical_url
130
+ asyncio.run(syndicate_secondary(art)) # phase 2 → canonical SEO everywhere
131
+ ```
132
+
133
+ ## Adding a platform
134
+
135
+ Adapters are registered in an open-closed registry. To add a platform, write
136
+ one async function satisfying the `PublishAdapter` protocol and register it —
137
+ no core changes:
138
+
139
+ ```python
140
+ from article import register_adapter, PublishResult
141
+ from article.util import require_canonical_url
142
+
143
+
144
+ @register_adapter("my_platform")
145
+ async def publish(article, *, canonical_url, config, secrets):
146
+ require_canonical_url("my_platform", canonical_url) # SSOT is never optional
147
+ # … call the platform's API, injecting canonical_url …
148
+ return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)
149
+ ```
150
+
151
+ ## Architecture
152
+
153
+ | Layer | Module | Responsibility |
154
+ |------|--------|----------------|
155
+ | Schema + config | `article/config.py` | Pydantic `Article` model, `.env` `Settings`, `load_article` (friendly errors) |
156
+ | Vocabulary | `article/base.py` | `PublishResult`, `RunSummary`, platform names, exceptions |
157
+ | State store | `article/state.py` | `MutableMapping` facade over JSON, keyed by slug (the SSOT) |
158
+ | Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
159
+ | Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
160
+ | Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
161
+ | CLI | `article/__main__.py` | `argh` dispatch of the two commands |
162
+
163
+ > **Status:** the layers above are complete and tested; each adapter's actual
164
+ > network / Playwright call is a clearly-marked `TODO` stub that returns a typed
165
+ > placeholder result. Payload field names are verified against each platform's
166
+ > live API (`canonicalUrl` / `canonical_url` / `originalArticleURL`).
@@ -0,0 +1,128 @@
1
+ # article
2
+
3
+ Tools to semi-automate the publication of articles.
4
+
5
+ Write an article **once** as a JSON file. Publish it to a **primary** platform
6
+ (Substack), then **syndicate** it to secondary platforms (Medium, Dev.to,
7
+ Hashnode) — each pointing its canonical URL back to the primary, so you get
8
+ cross-platform reach without an SEO duplicate-content penalty.
9
+
10
+ ```bash
11
+ pip install article
12
+ ```
13
+
14
+ ## The single source of truth
15
+
16
+ The Substack public URL captured in phase 1 is the SSOT. It is stored
17
+ (keyed by article slug) and injected as `canonical_url` into every secondary
18
+ platform in phase 2, so each emits `<link rel="canonical" href="…">` pointing
19
+ back at the original.
20
+
21
+ ```
22
+ author.json ──► publish-primary ──► Substack ──► live URL ─┐
23
+ │ stored as canonical_url
24
+ ▼ (pipeline_state.json, by slug)
25
+ syndicate-secondary ──► Medium / Dev.to / Hashnode
26
+ each canonical → Substack URL
27
+ ```
28
+
29
+ The two phases are deliberately separate commands so you can syndicate later
30
+ (e.g. `+3 days`) on your own schedule.
31
+
32
+ ## Two-command flow
33
+
34
+ ```bash
35
+ # 1. Author once, then publish to the primary (records the canonical URL):
36
+ python -m article publish-primary article.json
37
+
38
+ # 2. Later — syndicate everywhere, injecting the canonical URL for SEO:
39
+ python -m article syndicate-secondary article.json
40
+
41
+ # Subset / machine-readable output:
42
+ python -m article syndicate-secondary article.json --platforms medium,dev_to --json-out
43
+ ```
44
+
45
+ After `pip install`, the `article` console script is equivalent
46
+ (`article publish-primary article.json`).
47
+
48
+ ## The article JSON
49
+
50
+ One standardized file is the single source of truth for content **and** which
51
+ platforms to publish to — a platform is published only if it appears under
52
+ `platforms` (see [`examples/article.example.json`](examples/article.example.json)):
53
+
54
+ ```json
55
+ {
56
+ "title": "Designing an SSOT Publishing Pipeline",
57
+ "slug": "ssot-publishing-pipeline",
58
+ "content_markdown": "# Designing an SSOT Publishing Pipeline\n\n…",
59
+ "tags": ["python", "automation", "seo"],
60
+ "platforms": {
61
+ "substack": { "publish_as_draft": false },
62
+ "medium": { "publish_as_draft": true },
63
+ "dev_to": { "publish_as_draft": true },
64
+ "hashnode": { "publish_as_draft": true }
65
+ }
66
+ }
67
+ ```
68
+
69
+ ## Configuration
70
+
71
+ Secrets and settings load from `.env` (see
72
+ [`.env.example`](.env.example)) — copy it and fill in what you use:
73
+
74
+ ```bash
75
+ cp .env.example .env
76
+ ```
77
+
78
+ Every platform is optional; omit a platform's secrets (or its `platforms`
79
+ entry) to skip it.
80
+
81
+ ## Using it from Python
82
+
83
+ The CLI is a thin wrapper over the same functions, so everything is callable
84
+ programmatically:
85
+
86
+ ```python
87
+ import asyncio
88
+ from article import load_article, publish_primary, syndicate_secondary
89
+
90
+ art = load_article("article.json")
91
+ asyncio.run(publish_primary(art)) # phase 1 → records canonical_url
92
+ asyncio.run(syndicate_secondary(art)) # phase 2 → canonical SEO everywhere
93
+ ```
94
+
95
+ ## Adding a platform
96
+
97
+ Adapters are registered in an open-closed registry. To add a platform, write
98
+ one async function satisfying the `PublishAdapter` protocol and register it —
99
+ no core changes:
100
+
101
+ ```python
102
+ from article import register_adapter, PublishResult
103
+ from article.util import require_canonical_url
104
+
105
+
106
+ @register_adapter("my_platform")
107
+ async def publish(article, *, canonical_url, config, secrets):
108
+ require_canonical_url("my_platform", canonical_url) # SSOT is never optional
109
+ # … call the platform's API, injecting canonical_url …
110
+ return PublishResult.success("my_platform", url="…", canonical_url=canonical_url)
111
+ ```
112
+
113
+ ## Architecture
114
+
115
+ | Layer | Module | Responsibility |
116
+ |------|--------|----------------|
117
+ | Schema + config | `article/config.py` | Pydantic `Article` model, `.env` `Settings`, `load_article` (friendly errors) |
118
+ | Vocabulary | `article/base.py` | `PublishResult`, `RunSummary`, platform names, exceptions |
119
+ | State store | `article/state.py` | `MutableMapping` facade over JSON, keyed by slug (the SSOT) |
120
+ | Registry | `article/registry.py` | `PublishAdapter` protocol + open-closed registry |
121
+ | Engine | `article/engine.py` | Orchestrates phase 1 → phase 2; canonical handoff; graceful errors |
122
+ | Adapters | `article/adapters/*` | One module per platform (config + secrets injected) |
123
+ | CLI | `article/__main__.py` | `argh` dispatch of the two commands |
124
+
125
+ > **Status:** the layers above are complete and tested; each adapter's actual
126
+ > network / Playwright call is a clearly-marked `TODO` stub that returns a typed
127
+ > placeholder result. Payload field names are verified against each platform's
128
+ > live API (`canonicalUrl` / `canonical_url` / `originalArticleURL`).
@@ -0,0 +1,141 @@
1
+ """article — semi-automate single-source-of-truth article publishing.
2
+
3
+ Author an article once as a JSON file, publish it to a **primary** platform
4
+ (Substack), then **syndicate** it to secondaries (Medium, Dev.to, Hashnode)
5
+ with correct canonical-URL SEO. The Substack public URL captured in phase 1 is
6
+ the single source of truth (SSOT) injected as ``canonical_url`` into every
7
+ secondary in phase 2.
8
+
9
+ Quick start (Python)::
10
+
11
+ import asyncio
12
+ from article import load_article, publish_primary, syndicate_secondary
13
+
14
+ art = load_article("article.json")
15
+ asyncio.run(publish_primary(art)) # phase 1 -> records canonical_url
16
+ asyncio.run(syndicate_secondary(art)) # phase 2 -> canonical SEO everywhere
17
+
18
+ Or via the CLI::
19
+
20
+ python -m article publish-primary article.json
21
+ python -m article syndicate-secondary article.json
22
+
23
+ The public surface below is curated; everything else is an implementation
24
+ detail. Importing this package registers the built-in platform adapters.
25
+
26
+ >>> from article import load_article, available_adapters
27
+ >>> load_article({"title": "T", "slug": "t", "content_markdown": "x"}).slug
28
+ 't'
29
+ >>> available_adapters()
30
+ ('dev_to', 'hashnode', 'medium', 'substack')
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ # Vocabulary / result types / exceptions
36
+ from .base import (
37
+ ALL_PLATFORMS,
38
+ DEV_TO,
39
+ HASHNODE,
40
+ MEDIUM,
41
+ PRIMARY_PLATFORM,
42
+ SECONDARY_PLATFORMS,
43
+ SUBSTACK,
44
+ AdapterError,
45
+ ArticleError,
46
+ ArticleValidationError,
47
+ CanonicalUrlMissing,
48
+ ConfigError,
49
+ Phase,
50
+ PlatformName,
51
+ PublishResult,
52
+ PublishState,
53
+ RunSummary,
54
+ UnknownPlatformError,
55
+ )
56
+
57
+ # Domain schema + config facade
58
+ from .config import (
59
+ Article,
60
+ DevToConfig,
61
+ HashnodeConfig,
62
+ MediumConfig,
63
+ PlatformConfigs,
64
+ Settings,
65
+ SubstackConfig,
66
+ load_article,
67
+ load_settings,
68
+ )
69
+
70
+ # State store (MutableMapping facade)
71
+ from .state import JsonStateStore, default_state_store
72
+
73
+ # Adapter Protocol + open-closed registry
74
+ from .registry import (
75
+ PublishAdapter,
76
+ available_adapters,
77
+ get_adapter,
78
+ is_registered,
79
+ register_adapter,
80
+ )
81
+
82
+ # Orchestration engine + module-level facade
83
+ from .engine import (
84
+ PipelineEngine,
85
+ build_engine,
86
+ publish_primary,
87
+ syndicate_secondary,
88
+ )
89
+
90
+ # Importing the adapters package registers all built-in adapters.
91
+ from . import adapters # noqa: F401 (side-effect: populate the registry)
92
+
93
+ __all__ = [
94
+ # domain
95
+ "Article",
96
+ "PlatformConfigs",
97
+ "SubstackConfig",
98
+ "MediumConfig",
99
+ "DevToConfig",
100
+ "HashnodeConfig",
101
+ "load_article",
102
+ # config / settings
103
+ "Settings",
104
+ "load_settings",
105
+ # results / vocabulary
106
+ "PublishResult",
107
+ "PublishState",
108
+ "RunSummary",
109
+ "Phase",
110
+ "PlatformName",
111
+ "PRIMARY_PLATFORM",
112
+ "SECONDARY_PLATFORMS",
113
+ "ALL_PLATFORMS",
114
+ "SUBSTACK",
115
+ "MEDIUM",
116
+ "DEV_TO",
117
+ "HASHNODE",
118
+ # errors
119
+ "ArticleError",
120
+ "ArticleValidationError",
121
+ "ConfigError",
122
+ "AdapterError",
123
+ "UnknownPlatformError",
124
+ "CanonicalUrlMissing",
125
+ # state
126
+ "JsonStateStore",
127
+ "default_state_store",
128
+ # registry
129
+ "PublishAdapter",
130
+ "register_adapter",
131
+ "get_adapter",
132
+ "available_adapters",
133
+ "is_registered",
134
+ # engine
135
+ "PipelineEngine",
136
+ "build_engine",
137
+ "publish_primary",
138
+ "syndicate_secondary",
139
+ ]
140
+
141
+ __version__ = "0.1.0"
@@ -0,0 +1,106 @@
1
+ # PYTHON_ARGCOMPLETE_OK
2
+ """Command-line entry point for the ``article`` publishing pipeline.
3
+
4
+ Two commands, mirroring the two phases — both thin wrappers over the very same
5
+ :mod:`article.engine` functions the Python API exposes (dispatch-to-interface:
6
+ one implementation, many front-ends)::
7
+
8
+ python -m article publish-primary article.json
9
+ python -m article syndicate-secondary article.json
10
+
11
+ Common options: ``--env-file`` (where to read secrets), ``--state-path``
12
+ (the SSOT state file), ``--json-out`` (machine-readable summary). Phase 2 also
13
+ takes ``--platforms medium,dev_to`` to syndicate a subset.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from typing import Optional
20
+
21
+ import argh
22
+
23
+ from .base import ArticleError, RunSummary
24
+ from .config import load_article, load_settings
25
+ from .engine import publish_primary as _publish_primary
26
+ from .engine import syndicate_secondary as _syndicate_secondary
27
+ from .state import JsonStateStore
28
+ from .util import run_sync
29
+
30
+
31
+ def _render(summary: RunSummary, *, json_out: bool) -> str:
32
+ return json.dumps(summary.as_record(), indent=2) if json_out else summary.render()
33
+
34
+
35
+ def _prepare(article_path: str, *, env_file: Optional[str], state_path: Optional[str]):
36
+ """Shared setup: load settings + article + state store (with friendly errors)."""
37
+ try:
38
+ overrides = {"state_path": state_path} if state_path else {}
39
+ settings = load_settings(env_file=env_file, **overrides)
40
+ article = load_article(article_path)
41
+ except ArticleError as e:
42
+ raise argh.CommandError(str(e)) from e
43
+ store = JsonStateStore(settings.state_path)
44
+ return settings, article, store
45
+
46
+
47
+ @argh.arg("article_path", help="Path to the article JSON file")
48
+ def publish_primary(
49
+ article_path: str,
50
+ *,
51
+ env_file: Optional[str] = None,
52
+ state_path: Optional[str] = None,
53
+ json_out: bool = False,
54
+ ):
55
+ """Phase 1 — publish to the primary platform (Substack) and record canonical_url."""
56
+ settings, article, store = _prepare(
57
+ article_path, env_file=env_file, state_path=state_path
58
+ )
59
+ summary = run_sync(_publish_primary(article, settings=settings, store=store))
60
+ return _render(summary, json_out=json_out)
61
+
62
+
63
+ @argh.arg("article_path", help="Path to the article JSON file")
64
+ def syndicate_secondary(
65
+ article_path: str,
66
+ *,
67
+ env_file: Optional[str] = None,
68
+ state_path: Optional[str] = None,
69
+ platforms: Optional[str] = None,
70
+ json_out: bool = False,
71
+ ):
72
+ """Phase 2 — syndicate to secondary platforms, injecting canonical_url for SEO."""
73
+ settings, article, store = _prepare(
74
+ article_path, env_file=env_file, state_path=state_path
75
+ )
76
+ selected = [p.strip() for p in platforms.split(",")] if platforms else None
77
+ try:
78
+ summary = run_sync(
79
+ _syndicate_secondary(
80
+ article, settings=settings, store=store, platforms=selected
81
+ )
82
+ )
83
+ except ArticleError as e:
84
+ raise argh.CommandError(str(e)) from e
85
+ return _render(summary, json_out=json_out)
86
+
87
+
88
+ #: SSOT list of dispatchable commands (argh maps ``_`` in names to ``-``).
89
+ _dispatch_funcs = [publish_primary, syndicate_secondary]
90
+
91
+
92
+ def main() -> None:
93
+ """Assemble the argh parser and dispatch (with optional tab completion)."""
94
+ parser = argh.ArghParser()
95
+ parser.add_commands(_dispatch_funcs)
96
+ try:
97
+ import argcomplete
98
+
99
+ argcomplete.autocomplete(parser)
100
+ except ImportError:
101
+ pass
102
+ parser.dispatch()
103
+
104
+
105
+ if __name__ == "__main__":
106
+ main()
@@ -0,0 +1,17 @@
1
+ """Built-in platform adapters.
2
+
3
+ Importing this package has the side effect of registering every built-in
4
+ adapter in :mod:`article.registry`. The top-level :mod:`article` package
5
+ imports it, so the registry is populated as soon as ``article`` is imported.
6
+
7
+ To add a platform: drop a new module here that decorates an async function with
8
+ ``@register_adapter("<name>")``, then import it below. No core code changes.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # Side-effect imports: each module registers its adapter on import.
14
+ from . import substack # noqa: F401
15
+ from . import medium # noqa: F401
16
+ from . import dev_to # noqa: F401
17
+ from . import hashnode # noqa: F401