block-style-cms-sdk 0.0.1__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.
- block_style_cms_sdk-0.0.1/.gitignore +10 -0
- block_style_cms_sdk-0.0.1/PKG-INFO +107 -0
- block_style_cms_sdk-0.0.1/README.md +84 -0
- block_style_cms_sdk-0.0.1/pyproject.toml +49 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/__init__.py +25 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/client.py +248 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/markdown/__init__.py +1 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/markdown/images.py +66 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/models.py +76 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/resources/__init__.py +1 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/resources/articles.py +124 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/resources/content.py +23 -0
- block_style_cms_sdk-0.0.1/src/block_style_cms_sdk/resources/media.py +26 -0
- block_style_cms_sdk-0.0.1/tests/publish_sample_article.py +235 -0
- block_style_cms_sdk-0.0.1/uv.lock +292 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: block-style-cms-sdk
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Python SDK for Block Style CMS
|
|
5
|
+
Author-email: Biscuit Zhou <biscuit_zhou@outlook.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: blocknote,cms,markdown,sdk
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: httpx>=0.27
|
|
19
|
+
Requires-Dist: pydantic>=2.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: ruff>=0.9; extra == 'dev'
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# block-style-cms-sdk
|
|
25
|
+
|
|
26
|
+
Python SDK for [Block Style CMS](https://github.com/Byte-Biscuit/block-style-cms).
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install block-style-cms-sdk
|
|
32
|
+
# or: uv add block-style-cms-sdk
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Auth
|
|
36
|
+
|
|
37
|
+
Create an API token in the CMS admin UI. Pass it when constructing the client:
|
|
38
|
+
|
|
39
|
+
- Header: `x-api-key: <token>` (default), or
|
|
40
|
+
- `Authorization: Bearer <token>`
|
|
41
|
+
|
|
42
|
+
## Publish from Markdown
|
|
43
|
+
|
|
44
|
+
1. Collect relative images from markdown (e.g. `./assets/foo.png`)
|
|
45
|
+
2. Resolve files under the required `assetsDir`
|
|
46
|
+
3. Upload via `POST /api/m/images`
|
|
47
|
+
4. Rewrite paths to `/images/{filename}`
|
|
48
|
+
5. Submit via `POST /api/m/articles/from-markdown`
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from block_style_cms_sdk import BlockStyleCmsClient
|
|
52
|
+
|
|
53
|
+
client = BlockStyleCmsClient(
|
|
54
|
+
base_url="https://cms.example.com",
|
|
55
|
+
api_key="bsc_...",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
client.articles.publish_from_markdown(
|
|
59
|
+
title="Hello",
|
|
60
|
+
slug="hello-article-slug",
|
|
61
|
+
summary="A short summary of at least ten chars",
|
|
62
|
+
tags=["demo"],
|
|
63
|
+
language="en-US",
|
|
64
|
+
md_file="./posts/hello.md",
|
|
65
|
+
assets_dir="./posts/assets",
|
|
66
|
+
published=False,
|
|
67
|
+
)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Content metadata
|
|
71
|
+
|
|
72
|
+
Before publishing, discover site configuration:
|
|
73
|
+
|
|
74
|
+
- `GET /api/m/locales` — supported article languages (`zh-CN`, `zh-TW`, `en-US`)
|
|
75
|
+
- `GET /api/m/channels` — site channels from CMS settings
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
locales = client.content.list_locales()
|
|
79
|
+
channels = client.content.list_channels()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Develop
|
|
83
|
+
|
|
84
|
+
From `packages/python`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
uv sync --extra dev
|
|
88
|
+
uv run ruff check .
|
|
89
|
+
uv run ruff format .
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Remote API check with the shared sample fixture ([`samples/`](../../samples/)):
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
cd packages/python
|
|
96
|
+
BSC_BASE_URL=https://your-cms.example.com \
|
|
97
|
+
BSC_API_KEY=bsc_... \
|
|
98
|
+
uv run python tests/publish_sample_article.py
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Release
|
|
102
|
+
|
|
103
|
+
1. Bump `version` in [`pyproject.toml`](pyproject.toml).
|
|
104
|
+
2. Tag `py-vX.Y.Z`, push, and **Publish** a GitHub Release for that tag.
|
|
105
|
+
3. GitHub Actions uploads to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC; no `PYPI_TOKEN` secret).
|
|
106
|
+
|
|
107
|
+
Example: version `0.0.1` → tag `py-v0.0.1` → GitHub Release → PyPI.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# block-style-cms-sdk
|
|
2
|
+
|
|
3
|
+
Python SDK for [Block Style CMS](https://github.com/Byte-Biscuit/block-style-cms).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install block-style-cms-sdk
|
|
9
|
+
# or: uv add block-style-cms-sdk
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Auth
|
|
13
|
+
|
|
14
|
+
Create an API token in the CMS admin UI. Pass it when constructing the client:
|
|
15
|
+
|
|
16
|
+
- Header: `x-api-key: <token>` (default), or
|
|
17
|
+
- `Authorization: Bearer <token>`
|
|
18
|
+
|
|
19
|
+
## Publish from Markdown
|
|
20
|
+
|
|
21
|
+
1. Collect relative images from markdown (e.g. `./assets/foo.png`)
|
|
22
|
+
2. Resolve files under the required `assetsDir`
|
|
23
|
+
3. Upload via `POST /api/m/images`
|
|
24
|
+
4. Rewrite paths to `/images/{filename}`
|
|
25
|
+
5. Submit via `POST /api/m/articles/from-markdown`
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from block_style_cms_sdk import BlockStyleCmsClient
|
|
29
|
+
|
|
30
|
+
client = BlockStyleCmsClient(
|
|
31
|
+
base_url="https://cms.example.com",
|
|
32
|
+
api_key="bsc_...",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
client.articles.publish_from_markdown(
|
|
36
|
+
title="Hello",
|
|
37
|
+
slug="hello-article-slug",
|
|
38
|
+
summary="A short summary of at least ten chars",
|
|
39
|
+
tags=["demo"],
|
|
40
|
+
language="en-US",
|
|
41
|
+
md_file="./posts/hello.md",
|
|
42
|
+
assets_dir="./posts/assets",
|
|
43
|
+
published=False,
|
|
44
|
+
)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Content metadata
|
|
48
|
+
|
|
49
|
+
Before publishing, discover site configuration:
|
|
50
|
+
|
|
51
|
+
- `GET /api/m/locales` — supported article languages (`zh-CN`, `zh-TW`, `en-US`)
|
|
52
|
+
- `GET /api/m/channels` — site channels from CMS settings
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
locales = client.content.list_locales()
|
|
56
|
+
channels = client.content.list_channels()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Develop
|
|
60
|
+
|
|
61
|
+
From `packages/python`:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv sync --extra dev
|
|
65
|
+
uv run ruff check .
|
|
66
|
+
uv run ruff format .
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Remote API check with the shared sample fixture ([`samples/`](../../samples/)):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
cd packages/python
|
|
73
|
+
BSC_BASE_URL=https://your-cms.example.com \
|
|
74
|
+
BSC_API_KEY=bsc_... \
|
|
75
|
+
uv run python tests/publish_sample_article.py
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Release
|
|
79
|
+
|
|
80
|
+
1. Bump `version` in [`pyproject.toml`](pyproject.toml).
|
|
81
|
+
2. Tag `py-vX.Y.Z`, push, and **Publish** a GitHub Release for that tag.
|
|
82
|
+
3. GitHub Actions uploads to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (OIDC; no `PYPI_TOKEN` secret).
|
|
83
|
+
|
|
84
|
+
Example: version `0.0.1` → tag `py-v0.0.1` → GitHub Release → PyPI.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "block-style-cms-sdk"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Python SDK for Block Style CMS"
|
|
5
|
+
authors=[
|
|
6
|
+
{ name = "Biscuit Zhou", email = "biscuit_zhou@outlook.com" }
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
keywords = ["cms", "blocknote", "markdown", "sdk"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.27",
|
|
25
|
+
"pydantic>=2.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"ruff>=0.9",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/block_style_cms_sdk"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 88
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
src = ["src", "tests"]
|
|
44
|
+
|
|
45
|
+
[tool.ruff.lint]
|
|
46
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff.format]
|
|
49
|
+
quote-style = "double"
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Block Style CMS Python SDK."""
|
|
2
|
+
|
|
3
|
+
from .client import ApiError, BlockStyleCmsClient, HttpClient
|
|
4
|
+
from .markdown.images import (
|
|
5
|
+
collect_relative_image_srcs,
|
|
6
|
+
is_relative_image_src,
|
|
7
|
+
is_skippable_image_src,
|
|
8
|
+
rewrite_all_image_srcs,
|
|
9
|
+
rewrite_image_src,
|
|
10
|
+
)
|
|
11
|
+
from .resources.articles import MissingAssetsError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ApiError",
|
|
15
|
+
"BlockStyleCmsClient",
|
|
16
|
+
"HttpClient",
|
|
17
|
+
"MissingAssetsError",
|
|
18
|
+
"collect_relative_image_srcs",
|
|
19
|
+
"is_relative_image_src",
|
|
20
|
+
"is_skippable_image_src",
|
|
21
|
+
"rewrite_all_image_srcs",
|
|
22
|
+
"rewrite_image_src",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .models import (
|
|
11
|
+
ApiResult,
|
|
12
|
+
FromMarkdownPayload,
|
|
13
|
+
FromMarkdownRequest,
|
|
14
|
+
ImageMetaInfo,
|
|
15
|
+
ImageUploadOptions,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ApiError(Exception):
|
|
22
|
+
def __init__(self, message: str, code: int, payload: Any = None) -> None:
|
|
23
|
+
super().__init__(message)
|
|
24
|
+
self.code = code
|
|
25
|
+
self.payload = payload if payload is not None else {}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class HttpClient:
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
base_url: str,
|
|
33
|
+
api_key: str,
|
|
34
|
+
auth_style: Literal["api-key", "bearer"] = "api-key",
|
|
35
|
+
max_retries: int = 2,
|
|
36
|
+
timeout: float = 60.0,
|
|
37
|
+
) -> None:
|
|
38
|
+
if not base_url or not base_url.strip():
|
|
39
|
+
raise ValueError("base_url is required")
|
|
40
|
+
if not api_key or not api_key.strip():
|
|
41
|
+
raise ValueError("api_key is required")
|
|
42
|
+
self.base_url = base_url.strip().rstrip("/")
|
|
43
|
+
self.api_key = api_key.strip()
|
|
44
|
+
self.auth_style = auth_style
|
|
45
|
+
self.max_retries = max_retries
|
|
46
|
+
self._client = httpx.Client(timeout=timeout)
|
|
47
|
+
logger.debug(
|
|
48
|
+
"HttpClient base_url=%s auth_style=%s max_retries=%d timeout=%s",
|
|
49
|
+
self.base_url,
|
|
50
|
+
auth_style,
|
|
51
|
+
max_retries,
|
|
52
|
+
timeout,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def close(self) -> None:
|
|
56
|
+
self._client.close()
|
|
57
|
+
|
|
58
|
+
def __enter__(self) -> HttpClient:
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, *args: object) -> None:
|
|
62
|
+
self.close()
|
|
63
|
+
|
|
64
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
65
|
+
if self.auth_style == "bearer":
|
|
66
|
+
return {"Authorization": f"Bearer {self.api_key}"}
|
|
67
|
+
return {"x-api-key": self.api_key}
|
|
68
|
+
|
|
69
|
+
def request_json(
|
|
70
|
+
self,
|
|
71
|
+
method: str,
|
|
72
|
+
path: str,
|
|
73
|
+
*,
|
|
74
|
+
json: dict[str, Any] | None = None,
|
|
75
|
+
data: dict[str, Any] | None = None,
|
|
76
|
+
files: Any = None,
|
|
77
|
+
headers: dict[str, str] | None = None,
|
|
78
|
+
) -> ApiResult:
|
|
79
|
+
url = f"{self.base_url}{path if path.startswith('/') else '/' + path}"
|
|
80
|
+
hdrs = {**self._auth_headers(), **(headers or {})}
|
|
81
|
+
last_error: Exception | None = None
|
|
82
|
+
attempts = self.max_retries + 1
|
|
83
|
+
|
|
84
|
+
for attempt in range(attempts):
|
|
85
|
+
logger.debug("%s %s (attempt %d/%d)", method, url, attempt + 1, attempts)
|
|
86
|
+
try:
|
|
87
|
+
res = self._client.request(
|
|
88
|
+
method,
|
|
89
|
+
url,
|
|
90
|
+
json=json,
|
|
91
|
+
data=data,
|
|
92
|
+
files=files,
|
|
93
|
+
headers=hdrs,
|
|
94
|
+
)
|
|
95
|
+
body = res.json()
|
|
96
|
+
result = ApiResult.model_validate(body)
|
|
97
|
+
if res.status_code >= 500 or result.code >= 500:
|
|
98
|
+
if attempt < self.max_retries:
|
|
99
|
+
logger.warning(
|
|
100
|
+
"server error %s %s: HTTP %s code=%s "
|
|
101
|
+
"(attempt %d), retrying",
|
|
102
|
+
method,
|
|
103
|
+
url,
|
|
104
|
+
res.status_code,
|
|
105
|
+
result.code,
|
|
106
|
+
attempt + 1,
|
|
107
|
+
)
|
|
108
|
+
time.sleep(0.2 * (attempt + 1))
|
|
109
|
+
continue
|
|
110
|
+
if not res.is_success or result.code != 200:
|
|
111
|
+
logger.error(
|
|
112
|
+
"API error %s %s: HTTP %s code=%s message=%s",
|
|
113
|
+
method,
|
|
114
|
+
url,
|
|
115
|
+
res.status_code,
|
|
116
|
+
result.code,
|
|
117
|
+
result.message,
|
|
118
|
+
)
|
|
119
|
+
raise ApiError(
|
|
120
|
+
result.message or f"HTTP {res.status_code}",
|
|
121
|
+
result.code or res.status_code,
|
|
122
|
+
result.payload,
|
|
123
|
+
)
|
|
124
|
+
logger.debug(
|
|
125
|
+
"%s %s -> HTTP %s code=%s message=%s",
|
|
126
|
+
method,
|
|
127
|
+
url,
|
|
128
|
+
res.status_code,
|
|
129
|
+
result.code,
|
|
130
|
+
result.message,
|
|
131
|
+
)
|
|
132
|
+
return result
|
|
133
|
+
except ApiError:
|
|
134
|
+
raise
|
|
135
|
+
except Exception as err: # noqa: BLE001 — retry network blips
|
|
136
|
+
last_error = err
|
|
137
|
+
if attempt < self.max_retries:
|
|
138
|
+
logger.warning(
|
|
139
|
+
"request failed %s %s (attempt %d/%d): %s",
|
|
140
|
+
method,
|
|
141
|
+
url,
|
|
142
|
+
attempt + 1,
|
|
143
|
+
attempts,
|
|
144
|
+
err,
|
|
145
|
+
)
|
|
146
|
+
time.sleep(0.2 * (attempt + 1))
|
|
147
|
+
continue
|
|
148
|
+
logger.error(
|
|
149
|
+
"request failed %s %s after %d attempts: %s",
|
|
150
|
+
method,
|
|
151
|
+
url,
|
|
152
|
+
attempts,
|
|
153
|
+
err,
|
|
154
|
+
)
|
|
155
|
+
raise
|
|
156
|
+
assert last_error is not None
|
|
157
|
+
raise last_error
|
|
158
|
+
|
|
159
|
+
def upload_image(
|
|
160
|
+
self,
|
|
161
|
+
file_bytes: bytes,
|
|
162
|
+
filename: str,
|
|
163
|
+
options: ImageUploadOptions | None = None,
|
|
164
|
+
) -> ImageMetaInfo:
|
|
165
|
+
logger.info("uploading image %s (%d bytes)", filename, len(file_bytes))
|
|
166
|
+
files = {"file": (filename, file_bytes)}
|
|
167
|
+
data = None
|
|
168
|
+
if options is not None:
|
|
169
|
+
data = {
|
|
170
|
+
"options": options.model_dump_json(exclude_none=True),
|
|
171
|
+
}
|
|
172
|
+
result = self.request_json(
|
|
173
|
+
"POST",
|
|
174
|
+
"/api/m/images",
|
|
175
|
+
data=data,
|
|
176
|
+
files=files,
|
|
177
|
+
)
|
|
178
|
+
meta = ImageMetaInfo.model_validate(result.payload)
|
|
179
|
+
logger.info("uploaded image %s -> /images/%s", filename, meta.filename)
|
|
180
|
+
return meta
|
|
181
|
+
|
|
182
|
+
def upload_image_file(
|
|
183
|
+
self,
|
|
184
|
+
file_path: str | Path,
|
|
185
|
+
options: ImageUploadOptions | None = None,
|
|
186
|
+
) -> ImageMetaInfo:
|
|
187
|
+
path = Path(file_path)
|
|
188
|
+
logger.debug("reading image file %s", path)
|
|
189
|
+
return self.upload_image(path.read_bytes(), path.name, options)
|
|
190
|
+
|
|
191
|
+
def create_article_from_markdown(
|
|
192
|
+
self, body: FromMarkdownRequest
|
|
193
|
+
) -> FromMarkdownPayload:
|
|
194
|
+
logger.info(
|
|
195
|
+
"creating article from markdown slug=%s language=%s published=%s",
|
|
196
|
+
body.slug,
|
|
197
|
+
body.language,
|
|
198
|
+
body.published,
|
|
199
|
+
)
|
|
200
|
+
payload = body.model_dump(by_alias=True, exclude_none=True)
|
|
201
|
+
result = self.request_json(
|
|
202
|
+
"POST",
|
|
203
|
+
"/api/m/articles/from-markdown",
|
|
204
|
+
json=payload,
|
|
205
|
+
)
|
|
206
|
+
article = FromMarkdownPayload.model_validate(result.payload)
|
|
207
|
+
logger.info(
|
|
208
|
+
"created article id=%s slug=%s locale=%s",
|
|
209
|
+
article.id,
|
|
210
|
+
article.slug,
|
|
211
|
+
article.locale,
|
|
212
|
+
)
|
|
213
|
+
return article
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class BlockStyleCmsClient:
|
|
217
|
+
def __init__(
|
|
218
|
+
self,
|
|
219
|
+
*,
|
|
220
|
+
base_url: str,
|
|
221
|
+
api_key: str,
|
|
222
|
+
auth_style: Literal["api-key", "bearer"] = "api-key",
|
|
223
|
+
max_retries: int = 2,
|
|
224
|
+
timeout: float = 60.0,
|
|
225
|
+
) -> None:
|
|
226
|
+
from .resources.articles import ArticlesResource
|
|
227
|
+
from .resources.content import ContentResource
|
|
228
|
+
from .resources.media import MediaResource
|
|
229
|
+
|
|
230
|
+
self.http = HttpClient(
|
|
231
|
+
base_url=base_url,
|
|
232
|
+
api_key=api_key,
|
|
233
|
+
auth_style=auth_style,
|
|
234
|
+
max_retries=max_retries,
|
|
235
|
+
timeout=timeout,
|
|
236
|
+
)
|
|
237
|
+
self.media = MediaResource(self.http)
|
|
238
|
+
self.articles = ArticlesResource(self.http)
|
|
239
|
+
self.content = ContentResource(self.http)
|
|
240
|
+
|
|
241
|
+
def close(self) -> None:
|
|
242
|
+
self.http.close()
|
|
243
|
+
|
|
244
|
+
def __enter__(self) -> BlockStyleCmsClient:
|
|
245
|
+
return self
|
|
246
|
+
|
|
247
|
+
def __exit__(self, *args: object) -> None:
|
|
248
|
+
self.close()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Markdown image helpers."""
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
_MD_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\(([^)\s]+)(?:\s+\"[^\"]*\")?\)")
|
|
7
|
+
_HTML_IMG_RE = re.compile(r"<img\b[^>]*\bsrc=[\"']([^\"']+)[\"'][^>]*>", re.I)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def is_skippable_image_src(src: str) -> bool:
|
|
11
|
+
s = src.strip()
|
|
12
|
+
if not s:
|
|
13
|
+
return False
|
|
14
|
+
if s.startswith("/images/"):
|
|
15
|
+
return True
|
|
16
|
+
if re.match(r"^https?://", s, re.I):
|
|
17
|
+
return True
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_relative_image_src(src: str) -> bool:
|
|
22
|
+
s = src.strip()
|
|
23
|
+
if not s:
|
|
24
|
+
return True
|
|
25
|
+
if is_skippable_image_src(s):
|
|
26
|
+
return False
|
|
27
|
+
if s.startswith("/"):
|
|
28
|
+
return False
|
|
29
|
+
return True
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def collect_relative_image_srcs(md: str) -> list[str]:
|
|
33
|
+
found: set[str] = set()
|
|
34
|
+
for match in _MD_IMAGE_RE.finditer(md):
|
|
35
|
+
src = match.group(2).strip()
|
|
36
|
+
if is_relative_image_src(src):
|
|
37
|
+
found.add(src)
|
|
38
|
+
for match in _HTML_IMG_RE.finditer(md):
|
|
39
|
+
src = match.group(1).strip()
|
|
40
|
+
if is_relative_image_src(src):
|
|
41
|
+
found.add(src)
|
|
42
|
+
return sorted(found)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_image_path(assets_dir: str | Path, src: str) -> Path:
|
|
46
|
+
cleaned = src[2:] if src.startswith("./") else src
|
|
47
|
+
under_assets = cleaned[7:] if cleaned.startswith("assets/") else cleaned
|
|
48
|
+
return (Path(assets_dir) / under_assets).resolve()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def rewrite_image_src(md: str, from_src: str, to_src: str) -> str:
|
|
52
|
+
escaped = re.escape(from_src)
|
|
53
|
+
md_re = re.compile(rf"(!\[[^\]]*\]\(){escaped}((?:\s+\"[^\"]*\")?\))")
|
|
54
|
+
out = md_re.sub(rf"\g<1>{to_src}\g<2>", md)
|
|
55
|
+
html_re = re.compile(
|
|
56
|
+
rf"(<img\b[^>]*\bsrc=[\"']){escaped}([\"'])",
|
|
57
|
+
re.I,
|
|
58
|
+
)
|
|
59
|
+
return html_re.sub(rf"\g<1>{to_src}\g<2>", out)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def rewrite_all_image_srcs(md: str, mapping: dict[str, str]) -> str:
|
|
63
|
+
out = md
|
|
64
|
+
for from_src, to_src in mapping.items():
|
|
65
|
+
out = rewrite_image_src(out, from_src, to_src)
|
|
66
|
+
return out
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
Locale = Literal["zh-CN", "zh-TW", "en-US"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ApiResult(BaseModel):
|
|
11
|
+
code: int
|
|
12
|
+
message: str
|
|
13
|
+
payload: Any = Field(default_factory=dict)
|
|
14
|
+
tt: float | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ImageUploadOptions(BaseModel):
|
|
18
|
+
quality: int | None = None
|
|
19
|
+
maxWidth: int | None = None
|
|
20
|
+
maxHeight: int | None = None
|
|
21
|
+
enableWebP: bool | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ImageMetaInfo(BaseModel):
|
|
25
|
+
filename: str
|
|
26
|
+
originalName: str
|
|
27
|
+
size: int
|
|
28
|
+
width: int
|
|
29
|
+
height: int
|
|
30
|
+
mimeType: str
|
|
31
|
+
uploadedAt: str
|
|
32
|
+
fullUrl: str | None = None
|
|
33
|
+
title: str | None = None
|
|
34
|
+
description: str | None = None
|
|
35
|
+
tags: list[str] | None = None
|
|
36
|
+
altText: str | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FromMarkdownRequest(BaseModel):
|
|
40
|
+
title: str
|
|
41
|
+
slug: str
|
|
42
|
+
language: Locale
|
|
43
|
+
summary: str = ""
|
|
44
|
+
tags: list[str] = Field(default_factory=list)
|
|
45
|
+
keywords: list[str] = Field(default_factory=list)
|
|
46
|
+
mdContent: str | None = None
|
|
47
|
+
cover: str = ""
|
|
48
|
+
published: bool = False
|
|
49
|
+
locale_param: str | None = Field(default=None, alias="__locale__")
|
|
50
|
+
|
|
51
|
+
model_config = {"populate_by_name": True}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class FromMarkdownPayload(BaseModel):
|
|
55
|
+
id: str
|
|
56
|
+
slug: str
|
|
57
|
+
locale: Locale
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ChannelItemType = Literal["tag", "page"]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ChannelItem(BaseModel):
|
|
64
|
+
id: str
|
|
65
|
+
type: ChannelItemType
|
|
66
|
+
href: str
|
|
67
|
+
tag: str | None = None
|
|
68
|
+
icon: str | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class LocaleInfo(BaseModel):
|
|
72
|
+
code: Locale
|
|
73
|
+
name: str
|
|
74
|
+
nativeName: str
|
|
75
|
+
englishName: str
|
|
76
|
+
flag: str
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""API resource modules."""
|