@monoflake/sdk 0.0.0
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/LICENSE +22 -0
- package/dist/artifacts/src/anchors.d.ts +34 -0
- package/dist/artifacts/src/anchors.js +64 -0
- package/dist/artifacts/src/api.d.ts +14 -0
- package/dist/artifacts/src/api.js +20 -0
- package/dist/artifacts/src/batch.d.ts +105 -0
- package/dist/artifacts/src/batch.js +81 -0
- package/dist/artifacts/src/engagement.d.ts +61 -0
- package/dist/artifacts/src/engagement.js +67 -0
- package/dist/artifacts/src/feed.d.ts +42 -0
- package/dist/artifacts/src/feed.js +89 -0
- package/dist/artifacts/src/index.d.ts +4224 -0
- package/dist/artifacts/src/index.js +219 -0
- package/dist/artifacts/src/picture.d.ts +116 -0
- package/dist/artifacts/src/picture.js +161 -0
- package/dist/artifacts/src/resource.d.ts +1391 -0
- package/dist/artifacts/src/resource.js +477 -0
- package/dist/artifacts/src/schema.d.ts +5 -0
- package/dist/artifacts/src/schema.js +18 -0
- package/dist/artifacts/src/types.d.ts +396 -0
- package/dist/artifacts/src/types.js +0 -0
- package/dist/cache/src/index.d.ts +67 -0
- package/dist/cache/src/index.js +58 -0
- package/dist/imgsrc/src/index.d.ts +16 -0
- package/dist/imgsrc/src/index.js +89 -0
- package/dist/limits/src/bucket.d.ts +28 -0
- package/dist/limits/src/bucket.js +23 -0
- package/dist/limits/src/index.d.ts +29 -0
- package/dist/limits/src/index.js +62 -0
- package/dist/limits/src/key.d.ts +37 -0
- package/dist/limits/src/key.js +64 -0
- package/dist/robots/src/index.d.ts +98 -0
- package/dist/robots/src/index.js +196 -0
- package/dist/security/src/agents.d.ts +10 -0
- package/dist/security/src/agents.js +95 -0
- package/dist/security/src/index.d.ts +12 -0
- package/dist/security/src/index.js +37 -0
- package/dist/src/index.d.ts +218 -0
- package/dist/src/index.js +219 -0
- package/dist/store/src/index.d.ts +92 -0
- package/dist/store/src/index.js +264 -0
- package/dist/symlink/src/index.d.ts +24 -0
- package/dist/symlink/src/index.js +89 -0
- package/package.json +85 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Canmi <t@canmi.icu>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Block } from "./types.js";
|
|
2
|
+
//#region artifacts/src/anchors.d.ts
|
|
3
|
+
/** The name each anchored block's anchors take: what a reader sees, not how it is drawn. */
|
|
4
|
+
export declare const BLOCK_ANCHORS: {
|
|
5
|
+
readonly code: 'code';
|
|
6
|
+
readonly image: 'image';
|
|
7
|
+
readonly video: 'video';
|
|
8
|
+
readonly svgCanvas: 'diagram';
|
|
9
|
+
readonly mermaid: 'diagram';
|
|
10
|
+
readonly quadrant: 'chart';
|
|
11
|
+
readonly tokei: 'stats';
|
|
12
|
+
readonly cargo: 'crate';
|
|
13
|
+
readonly github: 'repo';
|
|
14
|
+
readonly twitter: 'tweet';
|
|
15
|
+
readonly linkcard: 'link';
|
|
16
|
+
readonly article: 'card';
|
|
17
|
+
};
|
|
18
|
+
/** Every name an anchor takes, once each. */
|
|
19
|
+
export declare const ANCHOR_KINDS: ("card" | "chart" | "code" | "crate" | "diagram" | "image" | "link" | "repo" | "stats" | "tweet" | "video")[];
|
|
20
|
+
/** A block anchor's kind and number, if `id` is one: `diagram-2`, not `code-review`. */
|
|
21
|
+
export declare function parseBlockAnchor(id: string): {
|
|
22
|
+
kind: string;
|
|
23
|
+
number: number;
|
|
24
|
+
} | undefined;
|
|
25
|
+
/** Whether a heading's id would take a block anchor's place, which it may not. */
|
|
26
|
+
export declare function isBlockAnchor(id: string): boolean;
|
|
27
|
+
/** Each block's anchor, in order, or `undefined` for a block that takes none. */
|
|
28
|
+
export declare function blockAnchors(blocks: readonly Pick<Block, 'type'>[]): (string | undefined)[];
|
|
29
|
+
/**
|
|
30
|
+
* Where a block anchor that names nothing lands instead: the same kind's nearest number, the lower
|
|
31
|
+
* one on a tie, among `present`. Nothing when `id` is no block anchor or none of its kind exists.
|
|
32
|
+
*/
|
|
33
|
+
export declare function nearestBlockAnchor(id: string, present: readonly string[]): string | undefined;
|
|
34
|
+
//#endregion
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
//#region artifacts/src/anchors.ts
|
|
2
|
+
/** The name each anchored block's anchors take: what a reader sees, not how it is drawn. */
|
|
3
|
+
const BLOCK_ANCHORS = {
|
|
4
|
+
code: "code",
|
|
5
|
+
image: "image",
|
|
6
|
+
video: "video",
|
|
7
|
+
svgCanvas: "diagram",
|
|
8
|
+
mermaid: "diagram",
|
|
9
|
+
quadrant: "chart",
|
|
10
|
+
tokei: "stats",
|
|
11
|
+
cargo: "crate",
|
|
12
|
+
github: "repo",
|
|
13
|
+
twitter: "tweet",
|
|
14
|
+
linkcard: "link",
|
|
15
|
+
article: "card"
|
|
16
|
+
};
|
|
17
|
+
/** Every name an anchor takes, once each. */
|
|
18
|
+
const ANCHOR_KINDS = [...new Set(Object.values(BLOCK_ANCHORS))];
|
|
19
|
+
const ANCHOR = new RegExp(`^(${ANCHOR_KINDS.join("|")})-([1-9]\\d*)$`);
|
|
20
|
+
/** A block anchor's kind and number, if `id` is one: `diagram-2`, not `code-review`. */
|
|
21
|
+
function parseBlockAnchor(id) {
|
|
22
|
+
const found = ANCHOR.exec(id);
|
|
23
|
+
return found ? {
|
|
24
|
+
kind: found[1],
|
|
25
|
+
number: Number(found[2])
|
|
26
|
+
} : void 0;
|
|
27
|
+
}
|
|
28
|
+
/** Whether a heading's id would take a block anchor's place, which it may not. */
|
|
29
|
+
function isBlockAnchor(id) {
|
|
30
|
+
return ANCHOR.test(id);
|
|
31
|
+
}
|
|
32
|
+
/** Each block's anchor, in order, or `undefined` for a block that takes none. */
|
|
33
|
+
function blockAnchors(blocks) {
|
|
34
|
+
const counts = /* @__PURE__ */ new Map();
|
|
35
|
+
return blocks.map(({ type }) => {
|
|
36
|
+
const kind = BLOCK_ANCHORS[type];
|
|
37
|
+
if (!kind) return void 0;
|
|
38
|
+
const number = (counts.get(kind) ?? 0) + 1;
|
|
39
|
+
counts.set(kind, number);
|
|
40
|
+
return `${kind}-${number}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Where a block anchor that names nothing lands instead: the same kind's nearest number, the lower
|
|
45
|
+
* one on a tie, among `present`. Nothing when `id` is no block anchor or none of its kind exists.
|
|
46
|
+
*/
|
|
47
|
+
function nearestBlockAnchor(id, present) {
|
|
48
|
+
const asked = parseBlockAnchor(id);
|
|
49
|
+
if (!asked) return void 0;
|
|
50
|
+
let best;
|
|
51
|
+
for (const candidate of present) {
|
|
52
|
+
const found = parseBlockAnchor(candidate);
|
|
53
|
+
if (!found || found.kind !== asked.kind) continue;
|
|
54
|
+
const distance = Math.abs(found.number - asked.number);
|
|
55
|
+
if (!best || distance < best.distance || distance === best.distance && found.number < best.number) best = {
|
|
56
|
+
id: candidate,
|
|
57
|
+
distance,
|
|
58
|
+
number: found.number
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return best?.id;
|
|
62
|
+
}
|
|
63
|
+
//#endregion
|
|
64
|
+
export { ANCHOR_KINDS, BLOCK_ANCHORS, blockAnchors, isBlockAnchor, nearestBlockAnchor, parseBlockAnchor };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { ApiError, ApiResponse, ApiSuccess, unwrap as unwrap$1 } from "@canmi/response";
|
|
3
|
+
//#region artifacts/src/api.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Open the envelope, then check that what was inside is what this call asked for.
|
|
6
|
+
*
|
|
7
|
+
* Two steps and one function, because every caller wants both and wanted them in that order. The
|
|
8
|
+
* envelope says whether the call worked; the schema says whether the payload is the shape the
|
|
9
|
+
* route promised. Kept here rather than in each consumer so that valibot stays this library's
|
|
10
|
+
* dependency and not everyone's. See spec/json.md.
|
|
11
|
+
*/
|
|
12
|
+
export declare function unwrapAs<S extends v.GenericSchema>(schema: S, body: unknown, source: string): v.InferOutput<S>;
|
|
13
|
+
//#endregion
|
|
14
|
+
export { type ApiError, type ApiResponse, type ApiSuccess, unwrap$1 as unwrap };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { unwrap, unwrap as unwrap$1 } from "@canmi/response";
|
|
3
|
+
//#region artifacts/src/api.ts
|
|
4
|
+
/**
|
|
5
|
+
* The envelope lives in lib/pkgs/response, beside its Rust half; these are its re-exports for the
|
|
6
|
+
* readers that already take their contracts from here.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Open the envelope, then check that what was inside is what this call asked for.
|
|
10
|
+
*
|
|
11
|
+
* Two steps and one function, because every caller wants both and wanted them in that order. The
|
|
12
|
+
* envelope says whether the call worked; the schema says whether the payload is the shape the
|
|
13
|
+
* route promised. Kept here rather than in each consumer so that valibot stays this library's
|
|
14
|
+
* dependency and not everyone's. See spec/json.md.
|
|
15
|
+
*/
|
|
16
|
+
function unwrapAs(schema, body, source) {
|
|
17
|
+
return v.parse(schema, unwrap(body, source));
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { unwrap$1 as unwrap, unwrapAs };
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Resource } from "./resource.js";
|
|
2
|
+
import { ViewAnswer } from "./index.js";
|
|
3
|
+
import * as v from "valibot";
|
|
4
|
+
import { LocaleCode } from "@canmi/me/locales";
|
|
5
|
+
//#region artifacts/src/batch.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Several articles in several languages, as the cross product.
|
|
8
|
+
*
|
|
9
|
+
* One shape for two gestures: a language menu is one slug and many locales, a homepage warming
|
|
10
|
+
* its list is many slugs and one locale. They were two routes and are one question.
|
|
11
|
+
*/
|
|
12
|
+
export declare const ArticlesRequestSchema: v.ObjectSchema<{
|
|
13
|
+
readonly type: v.LiteralSchema<"articles", undefined>;
|
|
14
|
+
readonly slugs: v.SchemaWithPipe<readonly [v.ArraySchema<v.StringSchema<undefined>, undefined>, v.MaxLengthAction<string[], 64, undefined>]>;
|
|
15
|
+
readonly locales: v.SchemaWithPipe<readonly [v.ArraySchema<v.PicklistSchema<readonly ["mw", "de", "en", "es", "fr", "ja", "ko", "zh", "tw"], undefined>, undefined>, v.MaxLengthAction<("de" | "en" | "es" | "fr" | "ja" | "ko" | "mw" | "tw" | "zh")[], 9, undefined>]>;
|
|
16
|
+
}, undefined>;
|
|
17
|
+
/** How many times each of these articles has been read, without any of it counting as a read. */
|
|
18
|
+
export declare const ReadsRequestSchema: v.ObjectSchema<{
|
|
19
|
+
readonly type: v.LiteralSchema<"reads", undefined>;
|
|
20
|
+
readonly slugs: v.SchemaWithPipe<readonly [v.ArraySchema<v.StringSchema<undefined>, undefined>, v.MaxLengthAction<string[], 64, undefined>]>;
|
|
21
|
+
}, undefined>;
|
|
22
|
+
/**
|
|
23
|
+
* How many resources one question carries.
|
|
24
|
+
*
|
|
25
|
+
* **A limit of the request and never of the page**, which is a difference a caller reading it the
|
|
26
|
+
* other way pays for with a blank article rather than a missing picture. `resourceQuestions` is
|
|
27
|
+
* that reading, written down so nobody has to arrive at it. See spec/architecture/resource.md,
|
|
28
|
+
* "One question per page". Measured: the heaviest article in this corpus names fifteen.
|
|
29
|
+
*/
|
|
30
|
+
export declare const RESOURCES_PER_QUESTION = 64;
|
|
31
|
+
/**
|
|
32
|
+
* What every rid on one page currently means, asked once.
|
|
33
|
+
*
|
|
34
|
+
* An arm here rather than a fan of `GET /media?resource=`, and bounded like the slugs above: one
|
|
35
|
+
* page's worth, not a walk of the corpus. Unchecked against the id pattern for the reason `slugs`
|
|
36
|
+
* is -- a string that could never be one comes back absent, which is the same answer sooner. See
|
|
37
|
+
* spec/architecture/resource.md, "One question per page, not one per resource".
|
|
38
|
+
*/
|
|
39
|
+
export declare const ResourcesRequestSchema: v.ObjectSchema<{
|
|
40
|
+
readonly type: v.LiteralSchema<"resources", undefined>;
|
|
41
|
+
readonly resources: v.SchemaWithPipe<readonly [v.ArraySchema<v.StringSchema<undefined>, undefined>, v.MaxLengthAction<string[], 64, undefined>]>;
|
|
42
|
+
}, undefined>;
|
|
43
|
+
/**
|
|
44
|
+
* The questions one page's rids become: deduplicated, and split where the request shape ends.
|
|
45
|
+
*
|
|
46
|
+
* One list in and one question out, for every page this corpus has and every page it plausibly
|
|
47
|
+
* grows -- the split is the tail nobody reaches, and it exists so that reaching it costs a second
|
|
48
|
+
* request rather than the page. Deduplicated here as well as at the collector, because a caller
|
|
49
|
+
* can hand this a list assembled from more than one view.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resourceQuestions(rids: readonly string[]): string[][];
|
|
52
|
+
/**
|
|
53
|
+
* What arrives at `/batch`, discriminated by `type`.
|
|
54
|
+
*
|
|
55
|
+
* A variant rather than a union of objects: valibot reads `type` first and reports the failure
|
|
56
|
+
* against that one branch, so a malformed `reads` body is not also reported as a bad `articles`.
|
|
57
|
+
*/
|
|
58
|
+
export declare const BatchRequestSchema: v.VariantSchema<"type", [v.ObjectSchema<{
|
|
59
|
+
readonly type: v.LiteralSchema<"articles", undefined>;
|
|
60
|
+
readonly slugs: v.SchemaWithPipe<readonly [v.ArraySchema<v.StringSchema<undefined>, undefined>, v.MaxLengthAction<string[], 64, undefined>]>;
|
|
61
|
+
readonly locales: v.SchemaWithPipe<readonly [v.ArraySchema<v.PicklistSchema<readonly ["mw", "de", "en", "es", "fr", "ja", "ko", "zh", "tw"], undefined>, undefined>, v.MaxLengthAction<("de" | "en" | "es" | "fr" | "ja" | "ko" | "mw" | "tw" | "zh")[], 9, undefined>]>;
|
|
62
|
+
}, undefined>, v.ObjectSchema<{
|
|
63
|
+
readonly type: v.LiteralSchema<"reads", undefined>;
|
|
64
|
+
readonly slugs: v.SchemaWithPipe<readonly [v.ArraySchema<v.StringSchema<undefined>, undefined>, v.MaxLengthAction<string[], 64, undefined>]>;
|
|
65
|
+
}, undefined>, v.ObjectSchema<{
|
|
66
|
+
readonly type: v.LiteralSchema<"resources", undefined>;
|
|
67
|
+
readonly resources: v.SchemaWithPipe<readonly [v.ArraySchema<v.StringSchema<undefined>, undefined>, v.MaxLengthAction<string[], 64, undefined>]>;
|
|
68
|
+
}, undefined>], undefined>;
|
|
69
|
+
export type BatchRequest = v.InferOutput<typeof BatchRequestSchema>;
|
|
70
|
+
export type BatchType = BatchRequest['type'];
|
|
71
|
+
/**
|
|
72
|
+
* One article's views, named once each.
|
|
73
|
+
*
|
|
74
|
+
* `path` and `url` sit above the views rather than inside every one, which is the only reason this
|
|
75
|
+
* is not a list of `/article` answers. The slug is not in here at all: it is the key this article
|
|
76
|
+
* is filed under, and repeating it inside would be the same fact twice.
|
|
77
|
+
*/
|
|
78
|
+
export type BatchedArticle = {
|
|
79
|
+
path: string;
|
|
80
|
+
url: string;
|
|
81
|
+
views: Partial<Record<LocaleCode, Omit<ViewAnswer, 'slug' | 'path' | 'url'>>>;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* What `/batch` answers, carrying back the `type` it was asked.
|
|
85
|
+
*
|
|
86
|
+
* Echoed rather than assumed: a consumer holding an answer can tell what it is an answer to
|
|
87
|
+
* without remembering what it sent. A slug or a rid the corpus does not name is absent rather
|
|
88
|
+
* than an error. Every map is keyed by what was asked with -- the identity, never the address --
|
|
89
|
+
* because an answer keyed by anything else could not be matched back to the question without the
|
|
90
|
+
* caller deriving one from the other.
|
|
91
|
+
*/
|
|
92
|
+
export type BatchAnswer = {
|
|
93
|
+
type: 'articles';
|
|
94
|
+
articles: Record<string, BatchedArticle>;
|
|
95
|
+
} | {
|
|
96
|
+
type: 'reads';
|
|
97
|
+
reads: Record<string, number>;
|
|
98
|
+
} | {
|
|
99
|
+
type: 'resources';
|
|
100
|
+
resources: Record<string, Resource>;
|
|
101
|
+
};
|
|
102
|
+
export type BatchAnswerOf<T extends BatchType> = Extract<BatchAnswer, {
|
|
103
|
+
type: T;
|
|
104
|
+
}>;
|
|
105
|
+
//#endregion
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
import { LOCALE_CODES } from "@canmi/me/locales";
|
|
3
|
+
//#region artifacts/src/batch.ts
|
|
4
|
+
/**
|
|
5
|
+
* The one batch entry point, and what may be asked through it.
|
|
6
|
+
*
|
|
7
|
+
* A single lookup is a `GET` with its identifiers in the query; asking about many things is a
|
|
8
|
+
* `POST` carrying a list, because a list does not belong in a URL. That left the API growing one
|
|
9
|
+
* route per batchable question -- `/views`, `/read-counts` -- each a second spelling of a question
|
|
10
|
+
* already answered singly, and each needing its own name.
|
|
11
|
+
*
|
|
12
|
+
* So there is one route, `POST /batch`, and the body says which question it is. `type` selects the
|
|
13
|
+
* module that reads the rest; adding a batchable question adds a variant here and a handler there,
|
|
14
|
+
* and no route at all. See spec/architecture/artifacts.md, "One batch entry point".
|
|
15
|
+
*/
|
|
16
|
+
/** A slug names an article; the list is bounded so one request cannot ask for the whole corpus. */
|
|
17
|
+
const slugs = v.pipe(v.array(v.string()), v.maxLength(64));
|
|
18
|
+
/**
|
|
19
|
+
* Several articles in several languages, as the cross product.
|
|
20
|
+
*
|
|
21
|
+
* One shape for two gestures: a language menu is one slug and many locales, a homepage warming
|
|
22
|
+
* its list is many slugs and one locale. They were two routes and are one question.
|
|
23
|
+
*/
|
|
24
|
+
const ArticlesRequestSchema = v.object({
|
|
25
|
+
type: v.literal("articles"),
|
|
26
|
+
slugs,
|
|
27
|
+
locales: v.pipe(v.array(v.picklist(LOCALE_CODES)), v.maxLength(LOCALE_CODES.length))
|
|
28
|
+
});
|
|
29
|
+
/** How many times each of these articles has been read, without any of it counting as a read. */
|
|
30
|
+
const ReadsRequestSchema = v.object({
|
|
31
|
+
type: v.literal("reads"),
|
|
32
|
+
slugs
|
|
33
|
+
});
|
|
34
|
+
/**
|
|
35
|
+
* How many resources one question carries.
|
|
36
|
+
*
|
|
37
|
+
* **A limit of the request and never of the page**, which is a difference a caller reading it the
|
|
38
|
+
* other way pays for with a blank article rather than a missing picture. `resourceQuestions` is
|
|
39
|
+
* that reading, written down so nobody has to arrive at it. See spec/architecture/resource.md,
|
|
40
|
+
* "One question per page". Measured: the heaviest article in this corpus names fifteen.
|
|
41
|
+
*/
|
|
42
|
+
const RESOURCES_PER_QUESTION = 64;
|
|
43
|
+
/**
|
|
44
|
+
* What every rid on one page currently means, asked once.
|
|
45
|
+
*
|
|
46
|
+
* An arm here rather than a fan of `GET /media?resource=`, and bounded like the slugs above: one
|
|
47
|
+
* page's worth, not a walk of the corpus. Unchecked against the id pattern for the reason `slugs`
|
|
48
|
+
* is -- a string that could never be one comes back absent, which is the same answer sooner. See
|
|
49
|
+
* spec/architecture/resource.md, "One question per page, not one per resource".
|
|
50
|
+
*/
|
|
51
|
+
const ResourcesRequestSchema = v.object({
|
|
52
|
+
type: v.literal("resources"),
|
|
53
|
+
resources: v.pipe(v.array(v.string()), v.maxLength(64))
|
|
54
|
+
});
|
|
55
|
+
/**
|
|
56
|
+
* The questions one page's rids become: deduplicated, and split where the request shape ends.
|
|
57
|
+
*
|
|
58
|
+
* One list in and one question out, for every page this corpus has and every page it plausibly
|
|
59
|
+
* grows -- the split is the tail nobody reaches, and it exists so that reaching it costs a second
|
|
60
|
+
* request rather than the page. Deduplicated here as well as at the collector, because a caller
|
|
61
|
+
* can hand this a list assembled from more than one view.
|
|
62
|
+
*/
|
|
63
|
+
function resourceQuestions(rids) {
|
|
64
|
+
const wanted = [...new Set(rids)];
|
|
65
|
+
const questions = [];
|
|
66
|
+
for (let from = 0; from < wanted.length; from += 64) questions.push(wanted.slice(from, from + 64));
|
|
67
|
+
return questions;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* What arrives at `/batch`, discriminated by `type`.
|
|
71
|
+
*
|
|
72
|
+
* A variant rather than a union of objects: valibot reads `type` first and reports the failure
|
|
73
|
+
* against that one branch, so a malformed `reads` body is not also reported as a bad `articles`.
|
|
74
|
+
*/
|
|
75
|
+
const BatchRequestSchema = v.variant("type", [
|
|
76
|
+
ArticlesRequestSchema,
|
|
77
|
+
ReadsRequestSchema,
|
|
78
|
+
ResourcesRequestSchema
|
|
79
|
+
]);
|
|
80
|
+
//#endregion
|
|
81
|
+
export { ArticlesRequestSchema, BatchRequestSchema, RESOURCES_PER_QUESTION, ReadsRequestSchema, ResourcesRequestSchema, resourceQuestions };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
//#region artifacts/src/engagement.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The two public counters, which belong to the site rather than to whoever is asking.
|
|
5
|
+
*
|
|
6
|
+
* Separate from `LikedAnswer` so that this one may be shared-cached and rendered on the server.
|
|
7
|
+
* See web's spec/engagement.md.
|
|
8
|
+
*/
|
|
9
|
+
export declare const StatsAnswerSchema: v.ObjectSchema<{
|
|
10
|
+
readonly subscriber_count: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
11
|
+
readonly like_count: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
12
|
+
}, undefined>;
|
|
13
|
+
/** Whether this visitor has liked. Per address, so never shared and never server-rendered. */
|
|
14
|
+
export declare const LikedAnswerSchema: v.ObjectSchema<{
|
|
15
|
+
readonly liked: v.BooleanSchema<undefined>;
|
|
16
|
+
}, undefined>;
|
|
17
|
+
/** Read counts by slug, for every slug asked for that names an article. */
|
|
18
|
+
export declare const ReadsAnswerSchema: v.ObjectSchema<{
|
|
19
|
+
readonly reads: v.RecordSchema<v.StringSchema<undefined>, v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
|
|
20
|
+
}, undefined>;
|
|
21
|
+
/**
|
|
22
|
+
* One article and its count, which is what both halves of the read counter answer.
|
|
23
|
+
*
|
|
24
|
+
* `GET /read` asks and `POST /read` records, and they share a shape because they answer the same
|
|
25
|
+
* question -- the `POST` differing only in that its figure includes the visit it just made. A
|
|
26
|
+
* second schema would be the same two fields under another name, and a consumer would have to
|
|
27
|
+
* know which one it was holding to read them. See web's spec/engagement.md.
|
|
28
|
+
*/
|
|
29
|
+
export declare const ReadAnswerSchema: v.ObjectSchema<{
|
|
30
|
+
readonly slug: v.StringSchema<undefined>;
|
|
31
|
+
readonly read_count: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
32
|
+
}, undefined>;
|
|
33
|
+
/** Taking or giving back a like. */
|
|
34
|
+
export declare const LikeAnswerSchema: v.ObjectSchema<{
|
|
35
|
+
readonly liked: v.BooleanSchema<undefined>;
|
|
36
|
+
readonly like_count: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
37
|
+
}, undefined>;
|
|
38
|
+
/**
|
|
39
|
+
* Subscribing, which answers with the token only the first time.
|
|
40
|
+
*
|
|
41
|
+
* A second subscription to an address already held answers without one, so that asking twice
|
|
42
|
+
* never hands out a capability over an existing subscription. See web's spec/engagement.md.
|
|
43
|
+
*/
|
|
44
|
+
export declare const NewsletterAnswerSchema: v.ObjectSchema<{
|
|
45
|
+
readonly email: v.StringSchema<undefined>;
|
|
46
|
+
readonly cancel_token: v.OptionalSchema<v.SchemaWithPipe<readonly [v.StringSchema<undefined>, v.RegexAction<string, undefined>]>, undefined>;
|
|
47
|
+
readonly subscriber_count: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
|
|
48
|
+
}, undefined>;
|
|
49
|
+
/** Cancelling. The count is absent when the answer is about a subscription that was not there. */
|
|
50
|
+
export declare const CancelAnswerSchema: v.ObjectSchema<{
|
|
51
|
+
readonly cancelled: v.OptionalSchema<v.BooleanSchema<undefined>, undefined>;
|
|
52
|
+
readonly subscriber_count: v.OptionalSchema<v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>, undefined>;
|
|
53
|
+
}, undefined>;
|
|
54
|
+
export type StatsAnswer = v.InferOutput<typeof StatsAnswerSchema>;
|
|
55
|
+
export type LikedAnswer = v.InferOutput<typeof LikedAnswerSchema>;
|
|
56
|
+
export type ReadsAnswer = v.InferOutput<typeof ReadsAnswerSchema>;
|
|
57
|
+
export type ReadAnswer = v.InferOutput<typeof ReadAnswerSchema>;
|
|
58
|
+
export type LikeAnswer = v.InferOutput<typeof LikeAnswerSchema>;
|
|
59
|
+
export type NewsletterAnswer = v.InferOutput<typeof NewsletterAnswerSchema>;
|
|
60
|
+
export type CancelAnswer = v.InferOutput<typeof CancelAnswerSchema>;
|
|
61
|
+
//#endregion
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as v from "valibot";
|
|
2
|
+
//#region artifacts/src/engagement.ts
|
|
3
|
+
/**
|
|
4
|
+
* What the engagement API answers, as schemas rather than as types.
|
|
5
|
+
*
|
|
6
|
+
* These are the answers a browser reads, and a browser is reading something it did not produce --
|
|
7
|
+
* across a network, from a Worker deployed on its own schedule. A type says what should arrive; a
|
|
8
|
+
* schema is the only thing that says what did. The corpus answers are checked by their envelope
|
|
9
|
+
* instead, which is cheaper and is all a content-addressed object needs; nothing here is
|
|
10
|
+
* content-addressed. See spec/architecture/artifacts.md, "Validation is heavy where it is free".
|
|
11
|
+
*
|
|
12
|
+
* Shared because both sides want the same sentence: the Worker builds an answer that satisfies
|
|
13
|
+
* the inferred type, and the browser parses what arrives against the schema it was inferred from.
|
|
14
|
+
*/
|
|
15
|
+
/** A counter is a whole number and never negative, which is most of what can go wrong with one. */
|
|
16
|
+
const counter = v.pipe(v.number(), v.integer(), v.minValue(0));
|
|
17
|
+
/** A capability token as the API mints it: 128 bits, lowercase hex. */
|
|
18
|
+
const cancelToken = v.pipe(v.string(), v.regex(/^[0-9a-f]{32}$/));
|
|
19
|
+
/**
|
|
20
|
+
* The two public counters, which belong to the site rather than to whoever is asking.
|
|
21
|
+
*
|
|
22
|
+
* Separate from `LikedAnswer` so that this one may be shared-cached and rendered on the server.
|
|
23
|
+
* See web's spec/engagement.md.
|
|
24
|
+
*/
|
|
25
|
+
const StatsAnswerSchema = v.object({
|
|
26
|
+
subscriber_count: counter,
|
|
27
|
+
like_count: counter
|
|
28
|
+
});
|
|
29
|
+
/** Whether this visitor has liked. Per address, so never shared and never server-rendered. */
|
|
30
|
+
const LikedAnswerSchema = v.object({ liked: v.boolean() });
|
|
31
|
+
/** Read counts by slug, for every slug asked for that names an article. */
|
|
32
|
+
const ReadsAnswerSchema = v.object({ reads: v.record(v.string(), counter) });
|
|
33
|
+
/**
|
|
34
|
+
* One article and its count, which is what both halves of the read counter answer.
|
|
35
|
+
*
|
|
36
|
+
* `GET /read` asks and `POST /read` records, and they share a shape because they answer the same
|
|
37
|
+
* question -- the `POST` differing only in that its figure includes the visit it just made. A
|
|
38
|
+
* second schema would be the same two fields under another name, and a consumer would have to
|
|
39
|
+
* know which one it was holding to read them. See web's spec/engagement.md.
|
|
40
|
+
*/
|
|
41
|
+
const ReadAnswerSchema = v.object({
|
|
42
|
+
slug: v.string(),
|
|
43
|
+
read_count: counter
|
|
44
|
+
});
|
|
45
|
+
/** Taking or giving back a like. */
|
|
46
|
+
const LikeAnswerSchema = v.object({
|
|
47
|
+
liked: v.boolean(),
|
|
48
|
+
like_count: counter
|
|
49
|
+
});
|
|
50
|
+
/**
|
|
51
|
+
* Subscribing, which answers with the token only the first time.
|
|
52
|
+
*
|
|
53
|
+
* A second subscription to an address already held answers without one, so that asking twice
|
|
54
|
+
* never hands out a capability over an existing subscription. See web's spec/engagement.md.
|
|
55
|
+
*/
|
|
56
|
+
const NewsletterAnswerSchema = v.object({
|
|
57
|
+
email: v.string(),
|
|
58
|
+
cancel_token: v.optional(cancelToken),
|
|
59
|
+
subscriber_count: counter
|
|
60
|
+
});
|
|
61
|
+
/** Cancelling. The count is absent when the answer is about a subscription that was not there. */
|
|
62
|
+
const CancelAnswerSchema = v.object({
|
|
63
|
+
cancelled: v.optional(v.boolean()),
|
|
64
|
+
subscriber_count: v.optional(counter)
|
|
65
|
+
});
|
|
66
|
+
//#endregion
|
|
67
|
+
export { CancelAnswerSchema, LikeAnswerSchema, LikedAnswerSchema, NewsletterAnswerSchema, ReadAnswerSchema, ReadsAnswerSchema, StatsAnswerSchema };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Block } from "./types.js";
|
|
2
|
+
import { LocaleCode } from "@canmi/me/locales";
|
|
3
|
+
//#region artifacts/src/feed.d.ts
|
|
4
|
+
/** Where the two absolute links a feed writes are rooted. */
|
|
5
|
+
export type FeedBases = {
|
|
6
|
+
/** The site's own origin, for an `::article` card pointing at another article here. */
|
|
7
|
+
site: string;
|
|
8
|
+
/**
|
|
9
|
+
* Where a bare resource id is resolved, already ending in a slash.
|
|
10
|
+
*
|
|
11
|
+
* A feed cannot pick a width and has nothing to resolve a rid with, so it names the id
|
|
12
|
+
* itself and lets the alias layer answer with the largest rendition that resource declares.
|
|
13
|
+
* That is what `ill.li/{rid}` is for from outside, and it is stable across a re-encode in a
|
|
14
|
+
* way a baked address never was. See spec/architecture/resource.md, "A bare id means
|
|
15
|
+
* whatever the resource says it means".
|
|
16
|
+
*/
|
|
17
|
+
resources: string;
|
|
18
|
+
/** The article's own URL, named by everything a feed cannot show in place. */
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* The view this document is, written into every link that stays on the site.
|
|
22
|
+
*
|
|
23
|
+
* Spelled out even for `mw`, where the bare address would do on a page. A bare URL negotiates
|
|
24
|
+
* from the reader's cookie, and nothing in a feed will correct that afterwards -- so a card
|
|
25
|
+
* showing a Japanese title has to name the Japanese view, and one in the source feed has to
|
|
26
|
+
* name the source. See web's spec/locale/views.md.
|
|
27
|
+
*/
|
|
28
|
+
locale: LocaleCode;
|
|
29
|
+
};
|
|
30
|
+
/** Exported because a feed body is not only blocks: see the site's translation notice. */
|
|
31
|
+
export declare function escapeHtml(value: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* One block as feed HTML, or nothing where the feed has nothing to say.
|
|
34
|
+
*
|
|
35
|
+
* A pending embed is the only `nothing`, and it is the reason a placeholder carries `pending` at
|
|
36
|
+
* all: what it would say is that `local embed` has not run, which is a fact about this repository
|
|
37
|
+
* rather than about the article.
|
|
38
|
+
*/
|
|
39
|
+
export declare function blockFeedHtml(block: Block, bases: FeedBases): string | undefined;
|
|
40
|
+
/** The whole body, one block to a line, in the order the article was written. */
|
|
41
|
+
export declare function feedHtml(blocks: readonly Block[], bases: FeedBases): string;
|
|
42
|
+
//#endregion
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { URLS } from "../../src/index.js";
|
|
2
|
+
//#region artifacts/src/feed.ts
|
|
3
|
+
/** Exported because a feed body is not only blocks: see the site's translation notice. */
|
|
4
|
+
function escapeHtml(value) {
|
|
5
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* What a feed says about a drawing it cannot show.
|
|
9
|
+
*
|
|
10
|
+
* The title is the fence's own when it has one and the article's otherwise, which in this corpus
|
|
11
|
+
* means the article's: no fence carries meta. So a described diagram says what it draws, and an
|
|
12
|
+
* undescribed one says only that it is there, which is all it ever said.
|
|
13
|
+
*/
|
|
14
|
+
function diagram(title, description, url) {
|
|
15
|
+
return `<p><em>[Diagram: ${escapeHtml(description ?? title)} — view at ${url}]</em></p>`;
|
|
16
|
+
}
|
|
17
|
+
function region(item, axes) {
|
|
18
|
+
const [vertical, horizontal] = item.at.split("-");
|
|
19
|
+
return `${axes[vertical]} / ${axes[horizontal]}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Name the view on every link that stays on this site.
|
|
23
|
+
*
|
|
24
|
+
* Prose is the page's own HTML, and a page links the bare address because its router carries the
|
|
25
|
+
* view across. A feed has no router, so a bare link resolves against whatever the reader's cookie
|
|
26
|
+
* holds. Left alone: an outside address, a bare fragment, and one that already names a language.
|
|
27
|
+
* See web's spec/locale/views.md.
|
|
28
|
+
*/
|
|
29
|
+
function pinView(html, { site, locale }) {
|
|
30
|
+
return html.replaceAll(/href="([^"]*)"/g, (whole, href) => {
|
|
31
|
+
if (!(href.startsWith(`${site}/`) || href === site || href.startsWith("/")) || /[?&]lang=/.test(href)) return whole;
|
|
32
|
+
const hash = href.indexOf("#");
|
|
33
|
+
const address = hash === -1 ? href : href.slice(0, hash);
|
|
34
|
+
const fragment = hash === -1 ? "" : href.slice(hash);
|
|
35
|
+
return `href="${address}${address.includes("?") ? "&" : "?"}lang=${locale}${fragment}"`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* One block as feed HTML, or nothing where the feed has nothing to say.
|
|
40
|
+
*
|
|
41
|
+
* A pending embed is the only `nothing`, and it is the reason a placeholder carries `pending` at
|
|
42
|
+
* all: what it would say is that `local embed` has not run, which is a fact about this repository
|
|
43
|
+
* rather than about the article.
|
|
44
|
+
*/
|
|
45
|
+
function blockFeedHtml(block, bases) {
|
|
46
|
+
switch (block.type) {
|
|
47
|
+
case "prose": return pinView(block.html, bases);
|
|
48
|
+
case "heading": {
|
|
49
|
+
const marks = (block.notes ?? []).map((number) => `<sup>${number}</sup>`).join("");
|
|
50
|
+
return `<h${block.depth} id="${block.slug}">${escapeHtml(block.text)}${marks}</h${block.depth}>`;
|
|
51
|
+
}
|
|
52
|
+
case "code": return `<pre><code>${escapeHtml(block.code)}</code></pre>`;
|
|
53
|
+
case "mermaid": return block.description ? diagram("diagram", block.description, bases.url) : `<pre><code class="language-mermaid">${escapeHtml(block.source)}</code></pre>`;
|
|
54
|
+
case "tokei": return `<pre>${escapeHtml(block.source)}</pre>`;
|
|
55
|
+
case "svgCanvas": return diagram(block.title, block.description, bases.url);
|
|
56
|
+
case "quadrant": {
|
|
57
|
+
const entries = block.items.map((item) => {
|
|
58
|
+
const note = item.note ? ` — ${escapeHtml(item.note)}` : "";
|
|
59
|
+
return `<li><strong>${escapeHtml(item.title)}</strong>${note} <small>(${escapeHtml(region(item, block.axes))})</small></li>`;
|
|
60
|
+
});
|
|
61
|
+
const caption = block.description ? ` — ${escapeHtml(block.description)}` : "";
|
|
62
|
+
return `<figure><figcaption><strong>${escapeHtml(block.title)}</strong>${caption}</figcaption><ul>${entries.join("")}</ul></figure>`;
|
|
63
|
+
}
|
|
64
|
+
case "linkcard": return `<p><a href="${block.url}">${escapeHtml(block.title)}</a></p>`;
|
|
65
|
+
case "article": return `<p><a href="${bases.site}/${block.path}?lang=${bases.locale}">${escapeHtml(block.title)}</a> — ${escapeHtml(block.subtitle)}</p>`;
|
|
66
|
+
case "image": return `<p><img src="${bases.resources}${block.resources.picture}" alt="${escapeHtml(block.alt)}" /></p>`;
|
|
67
|
+
case "video": return `<p>${block.poster ? `<img src="${block.poster}" alt="${escapeHtml(block.description ?? "")}" /> ` : ""}<em>[Video — watch at ${bases.url}]</em></p>`;
|
|
68
|
+
case "cargo": return `<p><em>[crate: ${escapeHtml(block.crate.name)} ${escapeHtml(block.crate.version)}]</em></p>`;
|
|
69
|
+
case "github": return `<p><em>[repository: ${escapeHtml(block.repo.full_name)}]</em></p>`;
|
|
70
|
+
case "twitter": {
|
|
71
|
+
const { tweet } = block;
|
|
72
|
+
const href = `${URLS.external.social.twitter}/${tweet.author}/status/${tweet.id}`;
|
|
73
|
+
return `<blockquote><p>${escapeHtml(tweet.text).replaceAll("\n", "<br />")}</p><footer><a href="${href}">@${escapeHtml(tweet.author)} on Twitter</a></footer></blockquote>`;
|
|
74
|
+
}
|
|
75
|
+
case "footnotes": return `<ol>${block.notes.map(({ number, phrase, text }) => `<li id="note-${number}"><strong>${escapeHtml(phrase)}</strong> ${escapeHtml(text)}</li>`).join("")}</ol>`;
|
|
76
|
+
case "placeholder": return block.pending ? void 0 : `<pre>::${escapeHtml(block.kind)}${Object.entries(block.meta).map(([key, value]) => `\n${key} = "${escapeHtml(value)}"`).join("")}</pre>`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** The whole body, one block to a line, in the order the article was written. */
|
|
80
|
+
function feedHtml(blocks, bases) {
|
|
81
|
+
const lines = [];
|
|
82
|
+
for (const block of blocks) {
|
|
83
|
+
const html = blockFeedHtml(block, bases);
|
|
84
|
+
if (html !== void 0) lines.push(html);
|
|
85
|
+
}
|
|
86
|
+
return lines.join("\n");
|
|
87
|
+
}
|
|
88
|
+
//#endregion
|
|
89
|
+
export { blockFeedHtml, escapeHtml, feedHtml };
|