@riebeckite/plugin-webmention 0.0.31 → 0.0.33
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.
- package/README.md +1 -1
- package/README_ja.md +18 -44
- package/dist/components/webmention-section.d.ts +2 -0
- package/dist/components/webmention-section.js +95 -0
- package/dist/index.d.ts +2 -4
- package/dist/index.js +25 -2
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -108,7 +108,7 @@ manifest time, mentions whose target is not a published entry are reported as
|
|
|
108
108
|
|
|
109
109
|
## Exports
|
|
110
110
|
|
|
111
|
-
- `webmention()` / `
|
|
111
|
+
- `webmention()` / `webmention()`
|
|
112
112
|
- `MemoryWebmentionProvider`, provider/capability types and errors
|
|
113
113
|
- `parseWebmentionSource`, `findTargetLink`, `verifyWebmention`,
|
|
114
114
|
`createWebmentionSourceFetcher`
|
package/README_ja.md
CHANGED
|
@@ -2,29 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- Generated from docs/docs/plugins/webmention.ja.md. Edit the canonical documentation in docs/docs/plugins and run `pnpm docs:sync`. -->
|
|
4
4
|
|
|
5
|
-
Webmention
|
|
6
|
-
プラガブルな provider 経由で保存して、検証済みのメンションを記事の近くに表示します。
|
|
7
|
-
コアプラグインには **Cloudflare・Worker・データベース・ベンダー固有のコードは一切なく**、
|
|
8
|
-
依存は `@riebeckite/core` だけです。
|
|
5
|
+
Webmention を受信し、送信元ドキュメントが本当にターゲットへリンクしているかを検証し、差し替え可能なプロバイダー経由で保存して、検証済みのメンションを記事の近くに表示します。コアプラグインには **Cloudflare・Worker・データベース・ベンダー固有のコードは一切なく**、依存は `@riebeckite/core` だけです。
|
|
9
6
|
|
|
10
7
|
[English](./README.md)
|
|
11
8
|
|
|
12
9
|
## 設計
|
|
13
10
|
|
|
14
|
-
- `WebmentionProvider`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
- `MemoryWebmentionProvider` はテスト・ローカルプレビューで使う provider の例として
|
|
19
|
-
同梱されています。プロセス/isolate をまたいで永続化はしません。
|
|
20
|
-
- プラグインは `endpoints` を宣言します。`POST {endpoint}` が Webmention を受信し、
|
|
21
|
-
`GET {endpoint}` が検証済みメンションのフィードを返します。ルートフレームワークの
|
|
22
|
-
詳細はこのパッケージへ入りません。HonoX 連携(`mountRiebeckiteEndpoints`)が
|
|
23
|
-
エンドポイントをホストルーターへマウントします。
|
|
24
|
-
- 検証は注入可能な `WebmentionSourceFetcher` を通じて送信元を取得し、ターゲットへの
|
|
25
|
-
発リンクを確認したうえで、軽量な引用メタデータ(title・excerpt・author・公開日・
|
|
26
|
-
`rel` 由来の種別)を記録します。既定の fetcher は loopback とプライベートネット
|
|
27
|
-
ワーク宛てを拒否し、本文サイズを制限し、タイムアウトします。
|
|
11
|
+
- `WebmentionProvider` がストレージとランタイムの境界です。`store` / `query` のケイパビリティを公開し、`store(mention)` と `query(query)` を持ちます。認証情報・データベースハンドル・実行時バインディングはアダプター内に留まり、プラグインの options や生成物へ漏れません。
|
|
12
|
+
- `MemoryWebmentionProvider` はテスト・ローカルプレビュー・プロバイダーの実装例として同梱されています。プロセスや isolate をまたいで永続化はしません。
|
|
13
|
+
- プラグインは `endpoints` を宣言します。`POST {endpoint}` が Webmention を受信し、`GET {endpoint}` が検証済みメンションのフィードを返します。ルートフレームワークの詳細はこのパッケージへ入りません。HonoX 連携(`mountRiebeckiteEndpoints`)がエンドポイントをホストルーターへマウントします。
|
|
14
|
+
- 検証は注入可能な `WebmentionSourceFetcher` を通じて送信元を取得し、ターゲットへの発リンクを確認したうえで、軽量な引用メタデータ(title・excerpt・author・公開日・`rel` 由来の種別)を記録します。既定のフェッチャーはループバックとプライベートネットワーク宛てを拒否し、本文サイズを制限し、タイムアウトします。
|
|
28
15
|
|
|
29
16
|
## 使い方
|
|
30
17
|
|
|
@@ -39,27 +26,21 @@ export default {
|
|
|
39
26
|
};
|
|
40
27
|
```
|
|
41
28
|
|
|
42
|
-
|
|
43
|
-
十分ですが、プロセスをまたぐとメンションは失われます。本産では永続 adapter
|
|
44
|
-
([`@riebeckite/webmention-cloudflare`](https://github.com/Rerurate514/riebeckite/blob/main/packages/integrations/webmention-cloudflare/README_ja.md) を参照)
|
|
45
|
-
を指定してください。
|
|
29
|
+
プロバイダーを省略するとインメモリのプロバイダーが使われます。ローカルプレビューには十分ですが、プロセスをまたぐとメンションは失われます。本番では永続アダプター([`@riebeckite/webmention-cloudflare`](https://github.com/Rerurate514/riebeckite/blob/main/packages/integrations/webmention-cloudflare/README_ja.md) を参照)を指定してください。
|
|
46
30
|
|
|
47
31
|
## エンドポイント
|
|
48
32
|
|
|
49
|
-
|
|
|
33
|
+
| メソッド | パス | 動作 |
|
|
50
34
|
| ------ | -------------------------- | ---- |
|
|
51
35
|
| `POST` | `/webmentions`(既定) | `source` と `target` を含む `application/x-www-form-urlencoded` または `application/json` を受け付けます。受理時は `202`、拒否時は `error` コード付きの `400`、ストレージ不能時は `503` を返します。 |
|
|
52
|
-
| `GET` | `/webmentions`(既定) | 検証済みメンションのフィードを JSON(`{ version, generatedAt, count, mentions }`)で返します。`?target=`・`?limit=`・`?since=` に対応。query
|
|
36
|
+
| `GET` | `/webmentions`(既定) | 検証済みメンションのフィードを JSON(`{ version, generatedAt, count, mentions }`)で返します。`?target=`・`?limit=`・`?since=` に対応。query に対応しないプロバイダーでは `501` を返します。 |
|
|
53
37
|
|
|
54
38
|
拒否コード: `invalid_request`、`missing_source_or_target`、`target_not_found`、
|
|
55
39
|
`invalid_source`、`invalid_target`、`source_unreachable`、`no_link_found`。
|
|
56
40
|
|
|
57
|
-
##
|
|
41
|
+
## 記事近くのメンション表示
|
|
58
42
|
|
|
59
|
-
|
|
60
|
-
同じインスタンスを `webmention()` へ渡してから、`getWebmentionSectionData` で
|
|
61
|
-
コンポーネントの型付き props を取得します。helper は `PublicContentView` を受け取るため、
|
|
62
|
-
draft と scheduled の entry は描画できません。
|
|
43
|
+
サイトが JSX の配置を所有します。プロバイダーのインスタンスはサイトの構成で保持し、同じインスタンスを `webmention()` へ渡してから、`getWebmentionSectionData` でコンポーネントの型付き props を取得します。ヘルパーは `PublicContentView` を受け取るため、draft と scheduled のエントリは描画できません。
|
|
63
44
|
|
|
64
45
|
```tsx
|
|
65
46
|
const mentions = await getWebmentionSectionData({
|
|
@@ -72,42 +53,35 @@ const mentions = await getWebmentionSectionData({
|
|
|
72
53
|
<WebmentionSection {...mentions} />
|
|
73
54
|
```
|
|
74
55
|
|
|
75
|
-
|
|
76
|
-
ビルド時に manifest HTML を注入しません。より低レベルな query が必要な場合は、
|
|
77
|
-
`getWebmentionsForEntry({ manifest, config, provider, slug })` を直接呼ぶか、JSON フィードを
|
|
78
|
-
利用できます。
|
|
56
|
+
空のプロバイダーは空のモデルを返し、`WebmentionSection` は何も描画しません。プラグインはビルド時にマニフェストの HTML を注入しません。より低レベルな query が必要な場合は、`getWebmentionsForEntry({ manifest, config, provider, slug })` を直接呼ぶか、JSON フィードを利用できます。
|
|
79
57
|
|
|
80
58
|
## オプション
|
|
81
59
|
|
|
82
60
|
| オプション | 既定値 | 説明 |
|
|
83
61
|
| ---------- | ------ | ---- |
|
|
84
|
-
| `provider` | インメモリ | `WebmentionProvider`
|
|
62
|
+
| `provider` | インメモリ | `WebmentionProvider` を実装するストレージアダプター。 |
|
|
85
63
|
| `endpoint` | `"/webmentions"` | 受信(POST)とフィード(GET)のパス。 |
|
|
86
64
|
| `headingText` | `"Mentions"` | 表示セクションの見出し。 |
|
|
87
65
|
| `limit` | `20` | 記事ごとに表示する最大メンション数。 |
|
|
88
66
|
| `className` | `"rr-webmention"` | 表示セクションのルート CSS クラス。 |
|
|
89
|
-
| `allowedTargets` | `[]` |
|
|
90
|
-
| `fetchSource` | グローバル `fetch` |
|
|
67
|
+
| `allowedTargets` | `[]` | 公開エントリ以外で受理する絶対ターゲット URL。 |
|
|
68
|
+
| `fetchSource` | グローバル `fetch` | 送信元フェッチャーの差し替え(テスト・独自ランタイム)。 |
|
|
91
69
|
| `timeoutMs` | `10000` | 送信元取得のタイムアウト。 |
|
|
92
70
|
| `maxBytes` | `1000000` | 受理する送信元ドキュメントの最大サイズ。 |
|
|
93
71
|
| `userAgent` | プラグイン既定 | 検証時の `User-Agent`。 |
|
|
94
72
|
| `allowPrivateHosts` | `false` | プライベートネットワーク宛ての取得を許可します。 |
|
|
95
73
|
| `nofollow` | `true` | 表示する送信元リンクへ `rel="nofollow ugc"` を付与します。 |
|
|
96
74
|
|
|
97
|
-
ブラウザへ渡るのは JSON
|
|
98
|
-
`publicConfig` も登録せず、provider の認証情報は options に入りません。
|
|
75
|
+
ブラウザへ渡るのは JSON 安全な値だけです。このプラグインはクライアントエントリも `publicConfig` も登録せず、プロバイダーの認証情報は options に入りません。
|
|
99
76
|
|
|
100
77
|
## 診断
|
|
101
78
|
|
|
102
|
-
`addDiagnostics`
|
|
103
|
-
(`webmention-render-requires-query`、`webmention-receive-requires-store`)を報告します。
|
|
104
|
-
manifest 生成時、公開 entry に一致しないターゲットのメンションは
|
|
105
|
-
`webmention-unmatched-target` として報告されます。
|
|
79
|
+
`addDiagnostics` はプロバイダーのケイパビリティ不足(`webmention-render-requires-query`、`webmention-receive-requires-store`)を報告します。マニフェスト生成時、公開エントリに一致しないターゲットのメンションは `webmention-unmatched-target` として報告されます。
|
|
106
80
|
|
|
107
81
|
## エクスポート
|
|
108
82
|
|
|
109
|
-
- `webmention()` / `
|
|
110
|
-
- `MemoryWebmentionProvider
|
|
83
|
+
- `webmention()` / `webmention()`
|
|
84
|
+
- `MemoryWebmentionProvider`、プロバイダーとケイパビリティの型およびエラー
|
|
111
85
|
- `parseWebmentionSource`、`findTargetLink`、`verifyWebmention`、
|
|
112
86
|
`createWebmentionSourceFetcher`
|
|
113
87
|
- `WebmentionSection`、`getWebmentionSectionData`、`getWebmentionsForEntry`、
|
|
@@ -6,3 +6,5 @@ export type WebmentionSectionProps = {
|
|
|
6
6
|
};
|
|
7
7
|
declare const WebmentionSection: ({ mentions, heading, className, }: Readonly<WebmentionSectionProps>) => import("hono/jsx/jsx-dev-runtime").JSX.Element;
|
|
8
8
|
export default WebmentionSection;
|
|
9
|
+
export type ArticleWebmentionSectionProps = Omit<WebmentionSectionProps, "mentions">;
|
|
10
|
+
export declare function ArticleWebmentionSection(props: Readonly<ArticleWebmentionSectionProps>): Promise<any[] | import("hono/utils/html").HtmlEscapedString>;
|
|
@@ -1,3 +1,75 @@
|
|
|
1
|
+
// components/webmention-section.tsx
|
|
2
|
+
import { resolvePlugins } from "@riebeckite/core";
|
|
3
|
+
import { useArticleContext, useSiteContext } from "@riebeckite/ui";
|
|
4
|
+
|
|
5
|
+
// src/provider.ts
|
|
6
|
+
function supportsWebmentionCapability(provider, capability) {
|
|
7
|
+
return provider.capabilities.has(capability);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/url.ts
|
|
11
|
+
function normalizeWebmentionUrl(value) {
|
|
12
|
+
let url;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(value);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
|
|
19
|
+
if (url.username !== "" || url.password !== "") return null;
|
|
20
|
+
url.hash = "";
|
|
21
|
+
url.hostname = url.hostname.toLowerCase();
|
|
22
|
+
return url.toString();
|
|
23
|
+
}
|
|
24
|
+
function resolvePublicUrl(permalink, baseUrl) {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(permalink, baseUrl ?? "https://example.invalid").toString();
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// src/targets.ts
|
|
33
|
+
function entryPublicUrl(entry, config) {
|
|
34
|
+
if (config?.site.baseUrl) {
|
|
35
|
+
return resolvePublicUrl(entry.permalink, config.site.baseUrl);
|
|
36
|
+
}
|
|
37
|
+
return normalizeWebmentionUrl(entry.permalink);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// src/server.ts
|
|
41
|
+
var getWebmentionSectionData = async ({
|
|
42
|
+
view,
|
|
43
|
+
slug,
|
|
44
|
+
config,
|
|
45
|
+
provider,
|
|
46
|
+
heading = "Mentions",
|
|
47
|
+
className = "rr-webmention",
|
|
48
|
+
limit
|
|
49
|
+
}) => {
|
|
50
|
+
const entry = view.findRoutableBySlug(slug);
|
|
51
|
+
if (!entry || !supportsWebmentionCapability(provider, "query")) {
|
|
52
|
+
return { mentions: [], heading, className };
|
|
53
|
+
}
|
|
54
|
+
const target = entryPublicUrl(entry, config);
|
|
55
|
+
if (target === null) return { mentions: [], heading, className };
|
|
56
|
+
const result = await provider.query({
|
|
57
|
+
type: "mentions_for_target",
|
|
58
|
+
target,
|
|
59
|
+
limit
|
|
60
|
+
});
|
|
61
|
+
return {
|
|
62
|
+
mentions: [...result.mentions].sort(compareMentions),
|
|
63
|
+
heading,
|
|
64
|
+
className
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
function compareMentions(left, right) {
|
|
68
|
+
return (right.publishedAt ?? right.verifiedAt).localeCompare(
|
|
69
|
+
left.publishedAt ?? left.verifiedAt
|
|
70
|
+
) || left.source.localeCompare(right.source);
|
|
71
|
+
}
|
|
72
|
+
|
|
1
73
|
// components/webmention-section.tsx
|
|
2
74
|
import { jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
3
75
|
var WebmentionSection = (({
|
|
@@ -40,6 +112,29 @@ var WebmentionSection = (({
|
|
|
40
112
|
);
|
|
41
113
|
});
|
|
42
114
|
var webmention_section_default = WebmentionSection;
|
|
115
|
+
async function ArticleWebmentionSection(props) {
|
|
116
|
+
const site = useSiteContext();
|
|
117
|
+
const article = useArticleContext();
|
|
118
|
+
if (!site || !article?.entry) return [];
|
|
119
|
+
const provider = findProvider(site.config.plugins);
|
|
120
|
+
if (!provider) return [];
|
|
121
|
+
const data = await getWebmentionSectionData({
|
|
122
|
+
view: site.view,
|
|
123
|
+
slug: article.entry.slug,
|
|
124
|
+
config: site.config,
|
|
125
|
+
provider,
|
|
126
|
+
heading: props.heading,
|
|
127
|
+
className: props.className
|
|
128
|
+
});
|
|
129
|
+
return /* @__PURE__ */ jsx(WebmentionSection, { ...data });
|
|
130
|
+
}
|
|
131
|
+
function findProvider(plugins) {
|
|
132
|
+
const options = resolvePlugins([...plugins]).find(
|
|
133
|
+
(plugin) => plugin.name === "webmention"
|
|
134
|
+
)?.options;
|
|
135
|
+
return options?.provider;
|
|
136
|
+
}
|
|
43
137
|
export {
|
|
138
|
+
ArticleWebmentionSection,
|
|
44
139
|
webmention_section_default as default
|
|
45
140
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { buildWebmentionDiagnostics } from "./src/diagnostics.js";
|
|
2
2
|
import type { WebmentionOptions } from "./src/types.js";
|
|
3
|
-
export type { WebmentionSectionProps } from "./components/webmention-section.js";
|
|
4
|
-
export { default as WebmentionSection } from "./components/webmention-section.js";
|
|
3
|
+
export type { ArticleWebmentionSectionProps, WebmentionSectionProps, } from "./components/webmention-section.js";
|
|
4
|
+
export { ArticleWebmentionSection, default as WebmentionSection, } from "./components/webmention-section.js";
|
|
5
5
|
export { createWebmentionEndpoints } from "./src/endpoints.js";
|
|
6
6
|
export type { WebmentionFeed, WebmentionFeedEntry, WebmentionFeedMention, } from "./src/feed.js";
|
|
7
7
|
export { buildFeed, createWebmentionFeedHandler, summarizeEntry, } from "./src/feed.js";
|
|
@@ -24,8 +24,6 @@ export { normalizeWebmentionUrl, resolvePublicUrl, urlComparisonKey, } from "./s
|
|
|
24
24
|
export type { WebmentionRejectionReason, WebmentionSourceDocument, WebmentionSourceFetcher, WebmentionSourceFetcherOptions, WebmentionVerification, } from "./src/verify.js";
|
|
25
25
|
export { createWebmentionSourceFetcher, DEFAULT_WEBMENTION_MAX_BYTES, DEFAULT_WEBMENTION_TIMEOUT_MS, DEFAULT_WEBMENTION_USER_AGENT, verifyWebmention, } from "./src/verify.js";
|
|
26
26
|
export type { WebmentionOptions as WebmentionPluginOptions };
|
|
27
|
-
/** Alias matching the `*Plugin` suffix used by other plugin factories. */
|
|
28
|
-
export declare const webmentionPlugin: typeof webmention;
|
|
29
27
|
/**
|
|
30
28
|
* Receives Webmentions and displays verified ones as "mentions" near articles.
|
|
31
29
|
*
|
package/dist/index.js
CHANGED
|
@@ -1055,6 +1055,8 @@ function compareMentions2(left, right) {
|
|
|
1055
1055
|
}
|
|
1056
1056
|
|
|
1057
1057
|
// components/webmention-section.tsx
|
|
1058
|
+
import { resolvePlugins } from "@riebeckite/core";
|
|
1059
|
+
import { useArticleContext, useSiteContext } from "@riebeckite/ui";
|
|
1058
1060
|
import { jsx, jsxs } from "hono/jsx/jsx-runtime";
|
|
1059
1061
|
var WebmentionSection = (({
|
|
1060
1062
|
mentions,
|
|
@@ -1096,6 +1098,28 @@ var WebmentionSection = (({
|
|
|
1096
1098
|
);
|
|
1097
1099
|
});
|
|
1098
1100
|
var webmention_section_default = WebmentionSection;
|
|
1101
|
+
async function ArticleWebmentionSection(props) {
|
|
1102
|
+
const site = useSiteContext();
|
|
1103
|
+
const article = useArticleContext();
|
|
1104
|
+
if (!site || !article?.entry) return [];
|
|
1105
|
+
const provider = findProvider(site.config.plugins);
|
|
1106
|
+
if (!provider) return [];
|
|
1107
|
+
const data = await getWebmentionSectionData({
|
|
1108
|
+
view: site.view,
|
|
1109
|
+
slug: article.entry.slug,
|
|
1110
|
+
config: site.config,
|
|
1111
|
+
provider,
|
|
1112
|
+
heading: props.heading,
|
|
1113
|
+
className: props.className
|
|
1114
|
+
});
|
|
1115
|
+
return /* @__PURE__ */ jsx(WebmentionSection, { ...data });
|
|
1116
|
+
}
|
|
1117
|
+
function findProvider(plugins) {
|
|
1118
|
+
const options = resolvePlugins([...plugins]).find(
|
|
1119
|
+
(plugin) => plugin.name === "webmention"
|
|
1120
|
+
)?.options;
|
|
1121
|
+
return options?.provider;
|
|
1122
|
+
}
|
|
1099
1123
|
|
|
1100
1124
|
// src/render.ts
|
|
1101
1125
|
import { escapeHtml, escapeHtmlAttribute } from "@riebeckite/core";
|
|
@@ -1132,7 +1156,6 @@ function formatDate(value) {
|
|
|
1132
1156
|
}
|
|
1133
1157
|
|
|
1134
1158
|
// index.ts
|
|
1135
|
-
var webmentionPlugin = webmention;
|
|
1136
1159
|
function webmention(options = {}) {
|
|
1137
1160
|
const resolved = resolveWebmentionOptions(options);
|
|
1138
1161
|
const provider = resolved.provider;
|
|
@@ -1221,6 +1244,7 @@ function webmention(options = {}) {
|
|
|
1221
1244
|
});
|
|
1222
1245
|
}
|
|
1223
1246
|
export {
|
|
1247
|
+
ArticleWebmentionSection,
|
|
1224
1248
|
DEFAULT_WEBMENTION_CLASS_NAME,
|
|
1225
1249
|
DEFAULT_WEBMENTION_ENDPOINT,
|
|
1226
1250
|
DEFAULT_WEBMENTION_HEADING_TEXT,
|
|
@@ -1266,6 +1290,5 @@ export {
|
|
|
1266
1290
|
verifyWebmention,
|
|
1267
1291
|
webmention,
|
|
1268
1292
|
webmentionKey,
|
|
1269
|
-
webmentionPlugin,
|
|
1270
1293
|
webmentionTypeForRels
|
|
1271
1294
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riebeckite/plugin-webmention",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.33",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
"./style.css": "./style.css"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"
|
|
25
|
-
"@riebeckite/
|
|
24
|
+
"@riebeckite/core": "0.0.33",
|
|
25
|
+
"@riebeckite/ui": "0.0.33",
|
|
26
|
+
"hono": "^4.12.25"
|
|
26
27
|
},
|
|
27
28
|
"devDependencies": {
|
|
28
29
|
"tsx": "^4.22.4"
|