@riebeckite/plugin-series 0.0.16 → 0.0.17

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 CHANGED
@@ -14,6 +14,10 @@ to each note in the series. Links use the permalinks resolved by Core, so
14
14
  plugins such as `permalink` are respected. A single-note series renders no
15
15
  navigation block.
16
16
 
17
+ The plugin also declares page types, so it exposes a list page at `/series`
18
+ and one landing page per series at `/series/<name>`. Both are built from the
19
+ discoverable manifest, so unlisted, draft, and scheduled notes never appear.
20
+
17
21
  The plugin is build-time only: it ships a stylesheet asset and no client
18
22
  runtime.
19
23
 
@@ -59,6 +63,7 @@ the numbered parts, using `date`/`created`/`published`, then `title`, then
59
63
  | `heading` | `boolean` | `true` | Render the series heading above the list. |
60
64
  | `className` | `string` | `"rb-series"` | Base CSS class for generated markup. |
61
65
  | `positionLabel` | `boolean` | `false` | Add a `Part N of M` label for the current note. |
66
+ | `basePath` | `string` | `"/series"` | Base path for the generated pages. An empty string disables them. |
62
67
 
63
68
  ## Output
64
69
 
@@ -91,6 +96,20 @@ All text and attributes are escaped. The injected HTML is written back to both
91
96
  the manifest entry and the processed content object so the page route, feeds,
92
97
  and search see the same markup.
93
98
 
99
+ ## Page types
100
+
101
+ The plugin registers two page types. Both derive their output from
102
+ `manifest.discoverableEntries`, so non-public notes are excluded.
103
+
104
+ | Page type | Path | Output |
105
+ | --------- | ---- | ------ |
106
+ | `series-list` | `<basePath>` (`/series`) | `<section class="rb-series rb-series--list">` listing each series with a link to its landing page. Generated only when at least one series exists. |
107
+ | `series-index` | `<basePath>/<name>` (`/series/<name>`) | The `renderSeriesIndex()` section for one series, in the existing order. |
108
+
109
+ The `<name>` segment is a lowercase, hyphenated slug; non-ASCII names fall back
110
+ to percent-encoding. Set `basePath` to match a custom routing scheme, or pass
111
+ `basePath: ""` to disable the pages and render them yourself.
112
+
94
113
  ## Exports
95
114
 
96
115
  - `series(options?)` / `seriesPlugin(options?)` — plugin factory
@@ -98,14 +117,23 @@ and search see the same markup.
98
117
  (`SeriesIndex | null`); useful for landing pages
99
118
  - `renderSeriesIndex(manifest, name, options?)` — standalone `<section>` block
100
119
  for a whole series
120
+ - `renderSeriesList(manifest, options?, label?)` — standalone `<section>` block
121
+ listing every series; returns `""` when there are none
122
+ - `seriesLandingPath(name, options?)` — landing path for a series, or `""` when
123
+ the pages are disabled
124
+ - `seriesSlug(name)` — URL segment used by a series landing page
101
125
  - `renderSeriesNavigation(index, currentSlug, options?)` — a single navigation
102
126
  block
103
127
  - `collectSeriesIndexes(manifest, options?)` — every series in first-seen order
104
128
  - `resolveSeriesOptions(options?)` — options with defaults applied
129
+ - `DEFAULT_SERIES_BASE_PATH` — default `basePath` (`"/series"`)
105
130
  - Types: `SeriesOptions`, `ResolvedSeriesOptions`, `SeriesMember`, `SeriesIndex`
106
131
 
107
132
  ### Series landing page
108
133
 
134
+ The plugin generates these pages automatically. To render them yourself, or to
135
+ reuse the markup elsewhere, combine the exported helpers:
136
+
109
137
  ```ts
110
138
  import { buildSeriesIndex, renderSeriesIndex } from "@riebeckite/plugin-series";
111
139
 
@@ -134,10 +162,12 @@ matched with `.rb-series__item a[aria-current="page"]`.
134
162
  ## Limitations
135
163
 
136
164
  - A note belongs to exactly one series.
137
- - A series of one note produces no navigation block.
165
+ - A series of one note produces no navigation block on the note, but it still
166
+ gets a list entry and a landing page.
138
167
  - `series_order` must be a finite number; numeric strings are not coerced.
139
- - The plugin does not generate routes for series; combine
140
- `renderSeriesIndex()` with your own page to build a series landing page.
168
+ - Series names that slugify to the same segment share the first landing page.
169
+ - Generated pages use `/series` by default; change `basePath` to avoid clashes
170
+ with existing routes.
141
171
 
142
172
  ## See also
143
173
 
package/README_ja.md CHANGED
@@ -8,6 +8,8 @@
8
8
 
9
9
  `series()` は frontmatter からシリーズ情報を読み取り、該当するノートをグループ化して並べ替えたうえで、各ノートの HTML に `<nav class="rb-series">` ブロックを追加します。リンクには Core が解決したパーマリンクを使うため、`permalink` プラグインなどとも併用できます。1 件だけのシリーズにはナビゲーションを出力しません。
10
10
 
11
+ また page type を登録し、一覧ページを `/series`、シリーズごとのランディングページを `/series/<name>` に生成します。どちらも公開対象(discoverable)の manifest から組み立てるため、非公開・下書き・予約投稿のノートは現れません。
12
+
11
13
  このプラグインはビルド時のみ動作し、クライアント用のランタイムは持ちません(スタイルシートのみを登録します)。
12
14
 
13
15
  ## 設定する
@@ -50,6 +52,7 @@ series_order: 2
50
52
  | `heading` | `boolean` | `true` | リストの上に見出しを表示する。 |
51
53
  | `className` | `string` | `"rb-series"` | 生成する HTML の基準クラス名。 |
52
54
  | `positionLabel` | `boolean` | `false` | 現在の記事に「Part N of M」を付ける。 |
55
+ | `basePath` | `string` | `"/series"` | 生成するページの基準パス。空文字にすると生成しない。 |
53
56
 
54
57
  ## 出力
55
58
 
@@ -79,18 +82,36 @@ series_order: 2
79
82
 
80
83
  テキストと属性はすべてエスケープします。生成した HTML は manifest の entry と処理済みコンテンツの両方へ書き戻すため、ページのルート表示・フィード・検索でも同じマークアップになります。
81
84
 
85
+ ## ページタイプ
86
+
87
+ プラグインは 2 つの page type を登録します。どちらも `manifest.discoverableEntries` から組み立てるため、非公開のノートは含まれません。
88
+
89
+ | page type | パス | 出力 |
90
+ | --------- | ---- | ---- |
91
+ | `series-list` | `<basePath>`(`/series`) | 各シリーズをランディングページへのリンク付きで並べた `<section class="rb-series rb-series--list">`。シリーズが 1 つ以上あるときだけ生成。 |
92
+ | `series-index` | `<basePath>/<name>`(`/series/<name>`) | 1 つのシリーズの `renderSeriesIndex()` セクション。既存の並び順をそのまま使う。 |
93
+
94
+ `<name>` は小文字化・ハイフン化したスラッグです。ASCII 以外の名前はパーセントエンコードにフォールバックします。`basePath` をカスタムルーティングに合わせて変更でき、`basePath: ""` で生成を止めて自前で描画できます。
95
+
82
96
  ## 公開 API
83
97
 
84
98
  - `series(options?)` / `seriesPlugin(options?)` — プラグインファクトリ
85
99
  - `buildSeriesIndex(manifest, name, options?)` — 1 つのシリーズの並び順付きメンバー(`SeriesIndex | null`)。ランディングページ向け
86
100
  - `renderSeriesIndex(manifest, name, options?)` — シリーズ全体の `<section>` ブロック
101
+ - `renderSeriesList(manifest, options?, label?)` — 全シリーズを並べた `<section>` ブロック。無ければ `""`
102
+ - `seriesLandingPath(name, options?)` — シリーズのランディングパス。生成無効時は `""`
103
+ - `seriesSlug(name)` — ランディングページの URL セグメント
87
104
  - `renderSeriesNavigation(index, currentSlug, options?)` — ナビゲーション 1 つ分
88
105
  - `collectSeriesIndexes(manifest, options?)` — 全シリーズを出現順で取得
89
106
  - `resolveSeriesOptions(options?)` — 既定値を適用したオプション
107
+ - `DEFAULT_SERIES_BASE_PATH` — 既定の `basePath`(`"/series"`)
90
108
  - 型: `SeriesOptions`, `ResolvedSeriesOptions`, `SeriesMember`, `SeriesIndex`
91
109
 
92
110
  ### シリーズのランディングページ
93
111
 
112
+ これらのページはプラグインが自動生成します。自前で描画したい場合や別の場所で
113
+ マークアップを再利用したい場合は、公開ヘルパーを組み合わせます。
114
+
94
115
  ```ts
95
116
  import { buildSeriesIndex, renderSeriesIndex } from "@riebeckite/plugin-series";
96
117
 
@@ -116,9 +137,10 @@ const html = renderSeriesIndex(manifest, "何かを作る");
116
137
  ## 制限
117
138
 
118
139
  - 1 つのノートが所属できるシリーズは 1 つだけです。
119
- - 1 件だけのシリーズにはナビゲーションを出力しません。
140
+ - 1 件だけのシリーズにはナビゲーションを出力しませんが、一覧の項目とランディングページは生成します。
120
141
  - `series_order` は有限の数値である必要があります。数値文字列は変換しません。
121
- - シリーズ用のルートは生成しません。ランディングページを作る場合は `renderSeriesIndex()` を自前のページと組み合わせてください。
142
+ - スラッグが同じになるシリーズ名は、最初のランディングページを共有します。
143
+ - 生成ページは既定で `/series` を使います。既存ルートと衝突する場合は `basePath` を変更してください。
122
144
 
123
145
  ## 関連
124
146
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolveSeriesOptions } from "./src/series.js";
2
2
  import type { SeriesOptions } from "./src/types.js";
3
- export { buildSeriesIndex, collectSeriesIndexes, renderSeriesIndex, renderSeriesNavigation, } from "./src/series.js";
3
+ export { buildSeriesIndex, collectSeriesIndexes, DEFAULT_SERIES_BASE_PATH, renderSeriesIndex, renderSeriesList, renderSeriesNavigation, seriesLandingPath, seriesSlug, } from "./src/series.js";
4
4
  export type { ResolvedSeriesOptions, SeriesIndex, SeriesMember, SeriesOptions, } from "./src/types.js";
5
5
  /**
6
6
  * Series (ordered multi-part posts) for Riebeckite.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // index.ts
2
2
  import {
3
+ appendContentBodySlot,
3
4
  createStyleAsset,
4
5
  definePlugin
5
6
  } from "@riebeckite/core";
@@ -10,6 +11,7 @@ import {
10
11
  escapeHtmlAttribute
11
12
  } from "@riebeckite/core";
12
13
  var DEFAULT_SERIES_CLASS_NAME = "rb-series";
14
+ var DEFAULT_SERIES_BASE_PATH = "/series";
13
15
  function resolveSeriesOptions(options = {}) {
14
16
  return {
15
17
  key: options.key ?? "series",
@@ -17,7 +19,8 @@ function resolveSeriesOptions(options = {}) {
17
19
  titleKey: options.titleKey ?? "series_title",
18
20
  heading: options.heading ?? true,
19
21
  className: options.className ?? DEFAULT_SERIES_CLASS_NAME,
20
- positionLabel: options.positionLabel ?? false
22
+ positionLabel: options.positionLabel ?? false,
23
+ basePath: normalizeBasePath(options.basePath ?? DEFAULT_SERIES_BASE_PATH)
21
24
  };
22
25
  }
23
26
  function collectSeriesIndexes(manifest, options = {}) {
@@ -47,6 +50,18 @@ function buildSeriesIndex(manifest, name, options = {}) {
47
50
  (index) => index.name === name
48
51
  ) ?? null;
49
52
  }
53
+ function seriesSlug(name) {
54
+ return name.split("/").map((segment) => {
55
+ const normalized = segment.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
56
+ return normalized === "" ? encodeURIComponent(segment.trim()) : normalized;
57
+ }).join("/");
58
+ }
59
+ function seriesLandingPath(name, options = {}) {
60
+ const resolved = resolveSeriesOptions(options);
61
+ if (resolved.basePath === "") return "";
62
+ const slug = seriesSlug(name);
63
+ return slug === "" ? "" : `${resolved.basePath}/${slug}`;
64
+ }
50
65
  function renderSeriesNavigation(index, currentSlug, options = {}) {
51
66
  const resolved = resolveSeriesOptions(options);
52
67
  const cls = resolved.className;
@@ -143,6 +158,21 @@ function renderSeriesIndex(manifest, name, options = {}) {
143
158
  parts.push("</ol></section>");
144
159
  return parts.join("");
145
160
  }
161
+ function renderSeriesList(manifest, options = {}, label = "Series") {
162
+ const indexes = collectSeriesIndexes(manifest, options);
163
+ if (indexes.length === 0) return "";
164
+ const resolved = resolveSeriesOptions(options);
165
+ const cls = escapeHtmlAttribute(resolved.className);
166
+ const items = indexes.map((index) => {
167
+ const path = seriesLandingPath(index.name, options);
168
+ const title = escapeHtml(index.title);
169
+ const link = path === "" ? `<span class="${cls}__link">${title}</span>` : `<a class="${cls}__link" href="${escapeHtmlAttribute(path)}">${title}</a>`;
170
+ return `<li class="${cls}__item" data-series-count="${index.members.length}">${link}</li>`;
171
+ }).join("");
172
+ return `<section class="${cls} ${cls}--list" data-series-list><h1 class="${cls}__title">${escapeHtml(
173
+ label
174
+ )}</h1><ol class="${cls}__list">${items}</ol></section>`;
175
+ }
146
176
  function collectSeriesDiagnostics(manifest, options = {}) {
147
177
  const resolved = resolveSeriesOptions(options);
148
178
  const diagnostics = [];
@@ -247,9 +277,44 @@ function hasFrontmatterKey(entry, key) {
247
277
  function formatPosition(position, total) {
248
278
  return `Part ${position} of ${total}`;
249
279
  }
280
+ function normalizeBasePath(value) {
281
+ const trimmed = value.trim();
282
+ if (trimmed === "") return "";
283
+ const prefixed = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
284
+ return prefixed.length > 1 ? prefixed.replace(/\/+$/, "") : prefixed;
285
+ }
250
286
 
251
287
  // index.ts
252
288
  function series(options = {}) {
289
+ const models = /* @__PURE__ */ new WeakMap();
290
+ const modelFor = (manifest) => {
291
+ const cached = models.get(manifest);
292
+ if (cached) return cached;
293
+ const resolved = resolveSeriesOptions(options);
294
+ const view = {
295
+ ...manifest,
296
+ entries: manifest.discoverableEntries
297
+ };
298
+ const byPath = /* @__PURE__ */ new Map();
299
+ const landingPaths = [];
300
+ if (resolved.basePath !== "") {
301
+ for (const index of collectSeriesIndexes(view, options)) {
302
+ const path = seriesLandingPath(index.name, options);
303
+ if (path === "" || byPath.has(path)) continue;
304
+ byPath.set(path, index);
305
+ landingPaths.push(path);
306
+ }
307
+ }
308
+ const model = {
309
+ resolved,
310
+ view,
311
+ listPath: resolved.basePath !== "" && landingPaths.length > 0 ? resolved.basePath : "",
312
+ landingPaths,
313
+ byPath
314
+ };
315
+ models.set(manifest, model);
316
+ return model;
317
+ };
253
318
  return definePlugin({
254
319
  name: "series",
255
320
  processedContentCache: {
@@ -261,12 +326,13 @@ function series(options = {}) {
261
326
  validateOptions: validateSeriesOptions,
262
327
  onManifestCreated: ({ manifest, diagnostics }) => {
263
328
  diagnostics.push(...collectSeriesDiagnostics(manifest, options));
329
+ const view = modelFor(manifest).view;
264
330
  const injected = /* @__PURE__ */ new Set();
265
- for (const index of collectSeriesIndexes(manifest, options)) {
331
+ for (const index of collectSeriesIndexes(view, options)) {
266
332
  if (index.members.length < 2) continue;
267
333
  for (const member of index.members) {
268
334
  if (injected.has(member.slug)) continue;
269
- const entry = manifest.bySlug.get(member.slug);
335
+ const entry = view.bySlug.get(member.slug);
270
336
  if (!entry) continue;
271
337
  injected.add(member.slug);
272
338
  const navigation = renderSeriesNavigation(
@@ -274,21 +340,73 @@ function series(options = {}) {
274
340
  member.slug,
275
341
  options
276
342
  );
277
- if (!entry.html.includes(navigation)) {
278
- entry.html = `${entry.html}
279
- ${navigation}`;
343
+ if (!hasSlotFragment(entry.bodySlots?.["article.footer"], navigation)) {
344
+ appendContentBodySlot(entry, "article.footer", navigation);
280
345
  }
281
346
  }
282
347
  }
283
348
  },
349
+ pageTypes: [
350
+ {
351
+ id: "series-list",
352
+ paths: ({ manifest }) => {
353
+ const listPath = modelFor(manifest).listPath;
354
+ return listPath === "" ? [] : [listPath];
355
+ },
356
+ outputDependencies: [{ type: "global" }],
357
+ resolve: ({ manifest, pathname }) => {
358
+ const model = modelFor(manifest);
359
+ if (model.listPath === "" || pathname !== model.listPath) return null;
360
+ const body = renderSeriesList(model.view, options);
361
+ if (body === "") return null;
362
+ return {
363
+ type: "series-list",
364
+ pathname,
365
+ title: "Series",
366
+ body
367
+ };
368
+ }
369
+ },
370
+ {
371
+ id: "series-index",
372
+ paths: ({ manifest }) => modelFor(manifest).landingPaths,
373
+ outputDependencies: [{ type: "global" }],
374
+ resolve: ({ manifest, pathname }) => {
375
+ const model = modelFor(manifest);
376
+ const index = model.byPath.get(pathname);
377
+ if (!index) return null;
378
+ const body = renderSeriesIndex(model.view, index.name, options);
379
+ if (body === "") return null;
380
+ return {
381
+ type: "series-index",
382
+ pathname,
383
+ title: index.title,
384
+ body
385
+ };
386
+ }
387
+ }
388
+ ],
284
389
  assets: [createStyleAsset("series")]
285
390
  });
286
391
  }
287
392
  var seriesPlugin = series;
393
+ function hasSlotFragment(slot, fragment) {
394
+ return slot === fragment || slot?.startsWith(`${fragment}
395
+ `) || slot?.endsWith(`
396
+ ${fragment}`) || slot?.includes(`
397
+ ${fragment}
398
+ `) || false;
399
+ }
288
400
  function validateSeriesOptions(options) {
289
401
  if (!options) return [];
290
402
  const issues = [];
291
- for (const key of ["key", "orderKey", "titleKey", "className"]) {
403
+ for (const key of [
404
+ "key",
405
+ "orderKey",
406
+ "titleKey",
407
+ "className",
408
+ "basePath"
409
+ ]) {
292
410
  const value = options[key];
293
411
  if (value !== void 0 && (typeof value !== "string" || value.trim() === "")) {
294
412
  issues.push({ path: key, message: "Expected a non-empty string." });
@@ -304,11 +422,15 @@ function validateSeriesOptions(options) {
304
422
  var DEFAULT_CLASS_NAME = resolveSeriesOptions({}).className;
305
423
  export {
306
424
  DEFAULT_CLASS_NAME,
425
+ DEFAULT_SERIES_BASE_PATH,
307
426
  buildSeriesIndex,
308
427
  collectSeriesIndexes,
309
428
  renderSeriesIndex,
429
+ renderSeriesList,
310
430
  renderSeriesNavigation,
311
431
  resolveSeriesOptions,
312
432
  series,
313
- seriesPlugin
433
+ seriesLandingPath,
434
+ seriesPlugin,
435
+ seriesSlug
314
436
  };
@@ -2,6 +2,7 @@ import { type ContentManifest, type Diagnostic } from "@riebeckite/core";
2
2
  import type { ResolvedSeriesOptions, SeriesIndex, SeriesOptions } from "./types.js";
3
3
  /** Base class applied when `options.className` is not set. */
4
4
  export declare const DEFAULT_SERIES_CLASS_NAME = "rb-series";
5
+ export declare const DEFAULT_SERIES_BASE_PATH = "/series";
5
6
  /** Applies defaults to the user-supplied options. */
6
7
  export declare function resolveSeriesOptions(options?: SeriesOptions): ResolvedSeriesOptions;
7
8
  /**
@@ -13,6 +14,8 @@ export declare function resolveSeriesOptions(options?: SeriesOptions): ResolvedS
13
14
  export declare function collectSeriesIndexes(manifest: ContentManifest, options?: SeriesOptions): SeriesIndex[];
14
15
  /** Returns a single series index, or `null` when no note declares `name`. */
15
16
  export declare function buildSeriesIndex(manifest: ContentManifest, name: string, options?: SeriesOptions): SeriesIndex | null;
17
+ export declare function seriesSlug(name: string): string;
18
+ export declare function seriesLandingPath(name: string, options?: SeriesOptions): string;
16
19
  /**
17
20
  * Renders the navigation block that is appended to each note in a series.
18
21
  * `currentSlug` marks the active part with `aria-current="page"` and drives the
@@ -24,6 +27,7 @@ export declare function renderSeriesNavigation(index: SeriesIndex, currentSlug:
24
27
  * Returns an empty string when the manifest has no member for `name`.
25
28
  */
26
29
  export declare function renderSeriesIndex(manifest: ContentManifest, name: string, options?: SeriesOptions): string;
30
+ export declare function renderSeriesList(manifest: ContentManifest, options?: SeriesOptions, label?: string): string;
27
31
  /**
28
32
  * Reports notes whose series metadata cannot be ordered reliably. Emitted
29
33
  * diagnostics use `pluginName: "series"` and `severity: "warning"`.
@@ -12,6 +12,7 @@ export type SeriesOptions = {
12
12
  className?: string;
13
13
  /** Render a `Part N of M` label for the current note. Defaults to `false`. */
14
14
  positionLabel?: boolean;
15
+ basePath?: string;
15
16
  };
16
17
  /** Options after defaults have been applied. */
17
18
  export type ResolvedSeriesOptions = {
@@ -21,6 +22,7 @@ export type ResolvedSeriesOptions = {
21
22
  heading: boolean;
22
23
  className: string;
23
24
  positionLabel: boolean;
25
+ basePath: string;
24
26
  };
25
27
  /** A single note that belongs to a series. */
26
28
  export type SeriesMember = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@riebeckite/plugin-series",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -15,11 +15,11 @@
15
15
  "./style.css": "./style.css"
16
16
  },
17
17
  "dependencies": {
18
- "@riebeckite/core": "0.0.16"
18
+ "@riebeckite/core": "0.0.17"
19
19
  },
20
20
  "devDependencies": {
21
21
  "tsx": "^4.22.4",
22
- "@riebeckite/test": "0.0.16"
22
+ "@riebeckite/test": "0.0.17"
23
23
  },
24
24
  "publishConfig": {
25
25
  "access": "public"