@sorane/okf 0.2.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/package.json +31 -0
- package/src/bundle.ts +70 -0
- package/src/digital-source-type.ts +200 -0
- package/src/extract.ts +26 -0
- package/src/index.ts +35 -0
- package/src/normalize.ts +120 -0
- package/src/parse.ts +28 -0
- package/src/serialize.ts +115 -0
- package/src/validate.ts +189 -0
- package/src/yaml.ts +16 -0
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sorane/okf",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Open Knowledge Format parsing, validation, and serialization for sorane",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/masanork/sorane.git",
|
|
10
|
+
"directory": "packages/okf"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://sorane.dev",
|
|
13
|
+
"bugs": "https://github.com/masanork/sorane/issues",
|
|
14
|
+
"files": [
|
|
15
|
+
"src"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts"
|
|
19
|
+
},
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=23.6"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"ajv": "^8.17.1",
|
|
28
|
+
"ajv-formats": "^3.0.1",
|
|
29
|
+
"js-yaml": "^4.1.0"
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createGzip } from "node:zlib";
|
|
2
|
+
import type { OkfConcept } from "./normalize.ts";
|
|
3
|
+
import { conceptToOkfMarkdown } from "./serialize.ts";
|
|
4
|
+
|
|
5
|
+
export interface BundleEntry {
|
|
6
|
+
readonly path: string;
|
|
7
|
+
readonly content: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface BundleConcept {
|
|
11
|
+
readonly concept: OkfConcept;
|
|
12
|
+
readonly slug: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 公開 concept を OKF bundle エントリに変換する({type}/{slug}.md)。 */
|
|
16
|
+
export function buildBundleEntries(concepts: readonly BundleConcept[]): BundleEntry[] {
|
|
17
|
+
return concepts
|
|
18
|
+
.slice()
|
|
19
|
+
.sort((a, b) => {
|
|
20
|
+
const pa = `${a.concept.type}/${a.slug}`;
|
|
21
|
+
const pb = `${b.concept.type}/${b.slug}`;
|
|
22
|
+
return pa < pb ? -1 : pa > pb ? 1 : 0;
|
|
23
|
+
})
|
|
24
|
+
.map((c) => ({
|
|
25
|
+
path: `${c.concept.type}/${c.slug}.md`,
|
|
26
|
+
content: conceptToOkfMarkdown(c.concept),
|
|
27
|
+
}));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 最小 tar(USTAR)を組み立てる。 */
|
|
31
|
+
function tarEntries(entries: readonly BundleEntry[]): Buffer {
|
|
32
|
+
const blocks: Buffer[] = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const content = Buffer.from(entry.content, "utf8");
|
|
35
|
+
const name = entry.path;
|
|
36
|
+
const header = Buffer.alloc(512, 0);
|
|
37
|
+
header.write(name.slice(0, 100), 0, "ascii");
|
|
38
|
+
header.write("0000644\0", 100, "ascii");
|
|
39
|
+
header.write("0000000\0", 108, "ascii");
|
|
40
|
+
header.write("0000000\0", 116, "ascii");
|
|
41
|
+
header.write(content.length.toString(8).padStart(11, "0") + "\0", 124, "ascii");
|
|
42
|
+
header.write(Math.floor(Date.now() / 1000).toString(8).padStart(11, "0") + "\0", 136, "ascii");
|
|
43
|
+
header.write(" ", 148, "ascii");
|
|
44
|
+
header.write("ustar\0", 257, "ascii");
|
|
45
|
+
header.write("00", 263, "ascii");
|
|
46
|
+
let checksum = 0;
|
|
47
|
+
for (let i = 0; i < 512; i++) checksum += header[i]!;
|
|
48
|
+
header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148, "ascii");
|
|
49
|
+
blocks.push(header, content);
|
|
50
|
+
const pad = (512 - (content.length % 512)) % 512;
|
|
51
|
+
if (pad > 0) blocks.push(Buffer.alloc(pad, 0));
|
|
52
|
+
}
|
|
53
|
+
blocks.push(Buffer.alloc(512, 0));
|
|
54
|
+
blocks.push(Buffer.alloc(512, 0));
|
|
55
|
+
return Buffer.concat(blocks);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** OKF bundle を gzip 圧縮した bytes として返す。 */
|
|
59
|
+
export function buildOkfBundle(concepts: readonly BundleConcept[]): Promise<Buffer> {
|
|
60
|
+
const entries = buildBundleEntries(concepts);
|
|
61
|
+
const tar = tarEntries(entries);
|
|
62
|
+
return new Promise((resolve, reject) => {
|
|
63
|
+
const chunks: Buffer[] = [];
|
|
64
|
+
const gzip = createGzip();
|
|
65
|
+
gzip.on("data", (c) => chunks.push(c));
|
|
66
|
+
gzip.on("end", () => resolve(Buffer.concat(chunks)));
|
|
67
|
+
gzip.on("error", reject);
|
|
68
|
+
gzip.end(tar);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
export const IPTC_BASE = "http://cv.iptc.org/newscodes/digitalsourcetype" as const;
|
|
2
|
+
|
|
3
|
+
export type EuAiLabel = "basic" | "fully-generated" | "partially-modified";
|
|
4
|
+
|
|
5
|
+
export const RETIRED_ALIASES: Readonly<Record<string, string>> = {
|
|
6
|
+
digitalArt: "digitalCreation",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const PHASE1_CODES = new Set([
|
|
10
|
+
"trainedAlgorithmicMedia",
|
|
11
|
+
"compositeWithTrainedAlgorithmicMedia",
|
|
12
|
+
"compositeSynthetic",
|
|
13
|
+
"algorithmicMedia",
|
|
14
|
+
"humanEdits",
|
|
15
|
+
"digitalCreation",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const EU_INFER: Readonly<Record<string, EuAiLabel | undefined>> = {
|
|
19
|
+
trainedAlgorithmicMedia: "fully-generated",
|
|
20
|
+
compositeWithTrainedAlgorithmicMedia: "partially-modified",
|
|
21
|
+
compositeSynthetic: "partially-modified",
|
|
22
|
+
algorithmicMedia: undefined,
|
|
23
|
+
humanEdits: undefined,
|
|
24
|
+
digitalCreation: undefined,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export interface ResolvedDigitalSourceType {
|
|
28
|
+
readonly uri: string;
|
|
29
|
+
readonly code: string;
|
|
30
|
+
readonly warnings: readonly string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function resolveDigitalSourceType(
|
|
34
|
+
raw: string,
|
|
35
|
+
): ResolvedDigitalSourceType | null {
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
if (trimmed.length === 0) return null;
|
|
38
|
+
|
|
39
|
+
let code: string;
|
|
40
|
+
const warnings: string[] = [];
|
|
41
|
+
|
|
42
|
+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
|
|
43
|
+
const normalized = trimmed.replace(/\/$/, "");
|
|
44
|
+
const prefix = `${IPTC_BASE}/`;
|
|
45
|
+
const httpUri = normalized.replace(/^https:\/\//, "http://");
|
|
46
|
+
if (!httpUri.startsWith(prefix)) return null;
|
|
47
|
+
code = httpUri.slice(prefix.length);
|
|
48
|
+
} else {
|
|
49
|
+
code = trimmed;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (code in RETIRED_ALIASES) {
|
|
53
|
+
const replacement = RETIRED_ALIASES[code]!;
|
|
54
|
+
warnings.push(`${code} is retired; use ${replacement}`);
|
|
55
|
+
code = replacement;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!PHASE1_CODES.has(code)) return null;
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
uri: `${IPTC_BASE}/${code}`,
|
|
62
|
+
code,
|
|
63
|
+
warnings,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function inferEuLabel(
|
|
68
|
+
code: string,
|
|
69
|
+
override?: EuAiLabel,
|
|
70
|
+
): EuAiLabel | undefined {
|
|
71
|
+
if (override) return override;
|
|
72
|
+
return EU_INFER[code];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function showsEuBadge(code: string, override?: EuAiLabel): boolean {
|
|
76
|
+
if (override) return true;
|
|
77
|
+
const inferred = EU_INFER[code];
|
|
78
|
+
return inferred !== undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export type EuAiLabelInput = EuAiLabel;
|
|
82
|
+
|
|
83
|
+
export function parseEuAiLabel(raw: unknown): EuAiLabel | undefined {
|
|
84
|
+
if (raw === "basic" || raw === "fully-generated" || raw === "partially-modified") {
|
|
85
|
+
return raw;
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AiSystemRef {
|
|
91
|
+
readonly name: string;
|
|
92
|
+
readonly version?: string;
|
|
93
|
+
readonly provider?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseAiSystems(raw: unknown): AiSystemRef[] | undefined {
|
|
97
|
+
if (!Array.isArray(raw) || raw.length === 0) return undefined;
|
|
98
|
+
const out: AiSystemRef[] = [];
|
|
99
|
+
for (const item of raw) {
|
|
100
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) return undefined;
|
|
101
|
+
const name = (item as Record<string, unknown>).name;
|
|
102
|
+
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
103
|
+
const version = (item as Record<string, unknown>).version;
|
|
104
|
+
const provider = (item as Record<string, unknown>).provider;
|
|
105
|
+
out.push({
|
|
106
|
+
name,
|
|
107
|
+
version: typeof version === "string" ? version : undefined,
|
|
108
|
+
provider: typeof provider === "string" ? provider : undefined,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const DISCLOSURE_KEYS = new Set([
|
|
115
|
+
"digitalSourceType",
|
|
116
|
+
"euAiLabel",
|
|
117
|
+
"aiDisclosureNote",
|
|
118
|
+
"aiSystems",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
export function hasDisclosureKeys(frontmatter: Record<string, unknown>): boolean {
|
|
122
|
+
return Object.keys(frontmatter).some((k) => DISCLOSURE_KEYS.has(k));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface DisclosureValidationIssue {
|
|
126
|
+
readonly path: string;
|
|
127
|
+
readonly message: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function validateDisclosureFields(
|
|
131
|
+
frontmatter: Record<string, unknown>,
|
|
132
|
+
strictCodes: boolean,
|
|
133
|
+
): { readonly issues: DisclosureValidationIssue[]; readonly warnings: string[] } {
|
|
134
|
+
const issues: DisclosureValidationIssue[] = [];
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
|
|
137
|
+
if (!hasDisclosureKeys(frontmatter)) {
|
|
138
|
+
return { issues, warnings };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const digitalSourceType = frontmatter.digitalSourceType;
|
|
142
|
+
const euAiLabel = frontmatter.euAiLabel;
|
|
143
|
+
const aiDisclosureNote = frontmatter.aiDisclosureNote;
|
|
144
|
+
const aiSystems = frontmatter.aiSystems;
|
|
145
|
+
|
|
146
|
+
const hasDst =
|
|
147
|
+
typeof digitalSourceType === "string" && digitalSourceType.trim().length > 0;
|
|
148
|
+
|
|
149
|
+
if (
|
|
150
|
+
(euAiLabel !== undefined && euAiLabel !== null && euAiLabel !== "") ||
|
|
151
|
+
(Array.isArray(aiSystems) && aiSystems.length > 0) ||
|
|
152
|
+
(typeof aiDisclosureNote === "string" && aiDisclosureNote.trim().length > 0)
|
|
153
|
+
) {
|
|
154
|
+
if (!hasDst) {
|
|
155
|
+
issues.push({
|
|
156
|
+
path: "digitalSourceType",
|
|
157
|
+
message: "digitalSourceType is required when other AI disclosure fields are set",
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (euAiLabel !== undefined && euAiLabel !== null && euAiLabel !== "") {
|
|
163
|
+
if (!parseEuAiLabel(euAiLabel)) {
|
|
164
|
+
issues.push({
|
|
165
|
+
path: "euAiLabel",
|
|
166
|
+
message: "euAiLabel must be basic, fully-generated, or partially-modified",
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (aiSystems !== undefined && parseAiSystems(aiSystems) === undefined) {
|
|
172
|
+
issues.push({
|
|
173
|
+
path: "aiSystems",
|
|
174
|
+
message: "aiSystems must be an array of { name, version?, provider? }",
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof aiDisclosureNote === "string" && aiDisclosureNote.length > 500) {
|
|
179
|
+
issues.push({
|
|
180
|
+
path: "aiDisclosureNote",
|
|
181
|
+
message: "aiDisclosureNote must be at most 500 characters",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (hasDst && typeof digitalSourceType === "string") {
|
|
186
|
+
const resolved = resolveDigitalSourceType(digitalSourceType);
|
|
187
|
+
if (!resolved) {
|
|
188
|
+
const msg = `unknown digitalSourceType: ${digitalSourceType}`;
|
|
189
|
+
if (strictCodes) {
|
|
190
|
+
issues.push({ path: "digitalSourceType", message: msg });
|
|
191
|
+
} else {
|
|
192
|
+
warnings.push(msg);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
warnings.push(...resolved.warnings);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return { issues, warnings };
|
|
200
|
+
}
|
package/src/extract.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OKF concept markdown から frontmatter を抽出する。
|
|
3
|
+
* gray-matter は使わない(CORE_SCHEMA との整合のため parse は yaml.ts に委ねる)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface ExtractResult {
|
|
7
|
+
readonly frontmatter: string | null;
|
|
8
|
+
readonly body: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
12
|
+
|
|
13
|
+
export function extract(source: string): ExtractResult {
|
|
14
|
+
const match = source.match(FRONTMATTER_RE);
|
|
15
|
+
if (!match) {
|
|
16
|
+
return { frontmatter: null, body: source };
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
frontmatter: match[1] ?? "",
|
|
20
|
+
body: source.slice(match[0].length),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function stripFrontmatter(source: string): string {
|
|
25
|
+
return extract(source).body;
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export { extract, stripFrontmatter } from "./extract.ts";
|
|
2
|
+
export { parseYaml, dumpYaml } from "./yaml.ts";
|
|
3
|
+
export { normalizeConcept, type OkfConcept } from "./normalize.ts";
|
|
4
|
+
export {
|
|
5
|
+
toOkfFrontmatterLines,
|
|
6
|
+
conceptToOkfMarkdown,
|
|
7
|
+
} from "./serialize.ts";
|
|
8
|
+
export {
|
|
9
|
+
validateSource,
|
|
10
|
+
validateProfileFormat,
|
|
11
|
+
resolveProfileSchema,
|
|
12
|
+
type ValidationResult,
|
|
13
|
+
type ValidationIssue,
|
|
14
|
+
} from "./validate.ts";
|
|
15
|
+
export {
|
|
16
|
+
IPTC_BASE,
|
|
17
|
+
resolveDigitalSourceType,
|
|
18
|
+
inferEuLabel,
|
|
19
|
+
showsEuBadge,
|
|
20
|
+
parseEuAiLabel,
|
|
21
|
+
parseAiSystems,
|
|
22
|
+
validateDisclosureFields,
|
|
23
|
+
hasDisclosureKeys,
|
|
24
|
+
PHASE1_CODES,
|
|
25
|
+
type EuAiLabel,
|
|
26
|
+
type AiSystemRef,
|
|
27
|
+
type ResolvedDigitalSourceType,
|
|
28
|
+
} from "./digital-source-type.ts";
|
|
29
|
+
export { parseConcept, type ParsedConcept } from "./parse.ts";
|
|
30
|
+
export {
|
|
31
|
+
buildBundleEntries,
|
|
32
|
+
buildOkfBundle,
|
|
33
|
+
type BundleConcept,
|
|
34
|
+
type BundleEntry,
|
|
35
|
+
} from "./bundle.ts";
|
package/src/normalize.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 入力 frontmatter を OKF native 表現に正規化する(純粋・決定論的)。
|
|
3
|
+
*
|
|
4
|
+
* 移行期の旧キー:
|
|
5
|
+
* layout/kind → type
|
|
6
|
+
* date/publishedAt → timestamp (ISO 8601)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface OkfConcept {
|
|
10
|
+
readonly type: string;
|
|
11
|
+
readonly title: string;
|
|
12
|
+
readonly body: string;
|
|
13
|
+
readonly frontmatter: Record<string, unknown>;
|
|
14
|
+
readonly timestamp?: string;
|
|
15
|
+
readonly description?: string;
|
|
16
|
+
readonly tags?: readonly string[];
|
|
17
|
+
readonly resource?: string;
|
|
18
|
+
readonly profile?: string;
|
|
19
|
+
readonly warnings: readonly string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveType(raw: Record<string, unknown>, warnings: string[]): string {
|
|
23
|
+
if (typeof raw.type === "string" && raw.type.length > 0) return raw.type;
|
|
24
|
+
if (typeof raw.kind === "string" && raw.kind.length > 0) {
|
|
25
|
+
warnings.push("deprecated: `kind` → use `type`");
|
|
26
|
+
return raw.kind;
|
|
27
|
+
}
|
|
28
|
+
if (raw.layout === "blog") {
|
|
29
|
+
warnings.push("deprecated: `layout: blog` → use `type: index`");
|
|
30
|
+
return "index";
|
|
31
|
+
}
|
|
32
|
+
if (raw.layout === "article") {
|
|
33
|
+
warnings.push("deprecated: `layout: article` → use `type: article`");
|
|
34
|
+
return "article";
|
|
35
|
+
}
|
|
36
|
+
return "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toIsoTimestamp(value: unknown): string | undefined {
|
|
40
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
41
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
42
|
+
return `${value}T00:00:00Z`;
|
|
43
|
+
}
|
|
44
|
+
const d = new Date(value);
|
|
45
|
+
if (Number.isNaN(d.getTime())) return value;
|
|
46
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value)) return value;
|
|
47
|
+
return d.toISOString();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveTimestamp(raw: Record<string, unknown>, warnings: string[]): string | undefined {
|
|
51
|
+
if (raw.timestamp !== undefined) {
|
|
52
|
+
const ts = toIsoTimestamp(raw.timestamp);
|
|
53
|
+
return ts;
|
|
54
|
+
}
|
|
55
|
+
if (raw.publishedAt !== undefined) {
|
|
56
|
+
warnings.push("deprecated: `publishedAt` → use `timestamp`");
|
|
57
|
+
return toIsoTimestamp(raw.publishedAt);
|
|
58
|
+
}
|
|
59
|
+
if (raw.date !== undefined) {
|
|
60
|
+
warnings.push("deprecated: `date` → use `timestamp`");
|
|
61
|
+
return toIsoTimestamp(raw.date);
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveTitle(raw: Record<string, unknown>, body: string, fallback: string): string {
|
|
67
|
+
if (typeof raw.title === "string" && raw.title.length > 0) return raw.title;
|
|
68
|
+
const m = body.match(/^#{1,6}\s+(.+?)\s*$/m);
|
|
69
|
+
if (m?.[1]) return m[1].trim();
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** frontmatter オブジェクト + 本文から OKF concept を組み立てる。 */
|
|
74
|
+
export function normalizeConcept(
|
|
75
|
+
raw: Record<string, unknown>,
|
|
76
|
+
body: string,
|
|
77
|
+
fallbackTitle: string,
|
|
78
|
+
): OkfConcept {
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
const type = resolveType(raw, warnings);
|
|
81
|
+
const title = resolveTitle(raw, body, fallbackTitle);
|
|
82
|
+
const timestamp = resolveTimestamp(raw, warnings);
|
|
83
|
+
|
|
84
|
+
const frontmatter: Record<string, unknown> = {};
|
|
85
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
86
|
+
if (key === "type" || key === "kind" || key === "layout") continue;
|
|
87
|
+
if (key === "timestamp" || key === "publishedAt" || key === "date") continue;
|
|
88
|
+
if (key === "title") continue;
|
|
89
|
+
if (value !== undefined) frontmatter[key] = value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const description =
|
|
93
|
+
typeof raw.description === "string" && raw.description.length > 0
|
|
94
|
+
? raw.description
|
|
95
|
+
: undefined;
|
|
96
|
+
const tags = Array.isArray(raw.tags)
|
|
97
|
+
? raw.tags.filter((t): t is string => typeof t === "string")
|
|
98
|
+
: undefined;
|
|
99
|
+
const resource =
|
|
100
|
+
typeof raw.resource === "string" && raw.resource.length > 0
|
|
101
|
+
? raw.resource
|
|
102
|
+
: undefined;
|
|
103
|
+
const profile =
|
|
104
|
+
typeof raw.profile === "string" && raw.profile.length > 0
|
|
105
|
+
? raw.profile
|
|
106
|
+
: undefined;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
type,
|
|
110
|
+
title,
|
|
111
|
+
body,
|
|
112
|
+
frontmatter,
|
|
113
|
+
timestamp,
|
|
114
|
+
description,
|
|
115
|
+
tags,
|
|
116
|
+
resource,
|
|
117
|
+
profile,
|
|
118
|
+
warnings,
|
|
119
|
+
};
|
|
120
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { extract } from "./extract.ts";
|
|
2
|
+
import { normalizeConcept, type OkfConcept } from "./normalize.ts";
|
|
3
|
+
import { parseYaml } from "./yaml.ts";
|
|
4
|
+
import { validateSource, type ValidationResult } from "./validate.ts";
|
|
5
|
+
|
|
6
|
+
export interface ParsedConcept {
|
|
7
|
+
readonly concept: OkfConcept;
|
|
8
|
+
readonly file: string;
|
|
9
|
+
readonly relPath: string;
|
|
10
|
+
readonly validation: ValidationResult;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function slugFromPath(relPath: string): string {
|
|
14
|
+
const base = relPath.replace(/\\/g, "/").split("/").pop() ?? relPath;
|
|
15
|
+
return base.replace(/\.md$/i, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** markdown ソースを parse + normalize する(検証結果も返す)。 */
|
|
19
|
+
export function parseConcept(file: string, relPath: string, source: string): ParsedConcept {
|
|
20
|
+
const validation = validateSource(relPath, source);
|
|
21
|
+
const { frontmatter, body } = extract(source);
|
|
22
|
+
const raw =
|
|
23
|
+
frontmatter !== null
|
|
24
|
+
? ((parseYaml(frontmatter) as Record<string, unknown>) ?? {})
|
|
25
|
+
: {};
|
|
26
|
+
const concept = normalizeConcept(raw, body, slugFromPath(relPath));
|
|
27
|
+
return { concept, file, relPath, validation };
|
|
28
|
+
}
|
package/src/serialize.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { OkfConcept } from "./normalize.ts";
|
|
2
|
+
|
|
3
|
+
const KEY_ORDER = [
|
|
4
|
+
"type",
|
|
5
|
+
"title",
|
|
6
|
+
"timestamp",
|
|
7
|
+
"description",
|
|
8
|
+
"resource",
|
|
9
|
+
"tags",
|
|
10
|
+
"profile",
|
|
11
|
+
"digitalSourceType",
|
|
12
|
+
"euAiLabel",
|
|
13
|
+
"aiDisclosureNote",
|
|
14
|
+
"aiSystems",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
function formatScalar(value: unknown): string {
|
|
18
|
+
if (typeof value === "boolean" || typeof value === "number") {
|
|
19
|
+
return String(value);
|
|
20
|
+
}
|
|
21
|
+
const s = String(value);
|
|
22
|
+
if (s.length === 0) return "''";
|
|
23
|
+
if (/^\s|\s$/.test(s)) return `'${s.replace(/'/g, "''")}'`;
|
|
24
|
+
if (/^[-?:,\[\]{}#&*!|>'"%@`]/.test(s)) return `'${s.replace(/'/g, "''")}'`;
|
|
25
|
+
if (/:(\s|$)/.test(s)) return `'${s.replace(/'/g, "''")}'`;
|
|
26
|
+
if (/\s#/.test(s)) return `'${s.replace(/'/g, "''")}'`;
|
|
27
|
+
if (/^(true|false|null|yes|no|on|off|~)$/i.test(s)) return `'${s.replace(/'/g, "''")}'`;
|
|
28
|
+
if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s)) return `'${s.replace(/'/g, "''")}'`;
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function appendAiSystemsEntry(
|
|
33
|
+
lines: string[],
|
|
34
|
+
key: string,
|
|
35
|
+
systems: readonly { name: string; version?: string; provider?: string }[],
|
|
36
|
+
): void {
|
|
37
|
+
lines.push(`${key}:`);
|
|
38
|
+
for (const s of systems) {
|
|
39
|
+
lines.push(` - name: ${formatScalar(s.name)}`);
|
|
40
|
+
if (s.version) lines.push(` version: ${formatScalar(s.version)}`);
|
|
41
|
+
if (s.provider) lines.push(` provider: ${formatScalar(s.provider)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function appendYamlEntry(lines: string[], key: string, value: unknown): void {
|
|
46
|
+
if (value === undefined) return;
|
|
47
|
+
if (Array.isArray(value)) {
|
|
48
|
+
if (value.length === 0) {
|
|
49
|
+
lines.push(`${key}: []`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
lines.push(`${key}:`);
|
|
53
|
+
for (const item of value) {
|
|
54
|
+
lines.push(` - ${formatScalar(item)}`);
|
|
55
|
+
}
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (value !== null && typeof value === "object") {
|
|
59
|
+
lines.push(`${key}:`);
|
|
60
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
61
|
+
if (v === undefined) continue;
|
|
62
|
+
lines.push(` ${k}: ${formatScalar(v)}`);
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
lines.push(`${key}: ${formatScalar(value)}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** OKF native frontmatter 行を組み立てる(旧キーは出力しない)。 */
|
|
70
|
+
export function toOkfFrontmatterLines(concept: OkfConcept): string[] {
|
|
71
|
+
const lines: string[] = [];
|
|
72
|
+
lines.push(`type: ${formatScalar(concept.type)}`);
|
|
73
|
+
lines.push(`title: ${formatScalar(concept.title)}`);
|
|
74
|
+
if (concept.timestamp) lines.push(`timestamp: ${formatScalar(concept.timestamp)}`);
|
|
75
|
+
if (concept.description) lines.push(`description: ${formatScalar(concept.description)}`);
|
|
76
|
+
if (concept.resource) lines.push(`resource: ${formatScalar(concept.resource)}`);
|
|
77
|
+
if (concept.tags && concept.tags.length > 0) appendYamlEntry(lines, "tags", [...concept.tags]);
|
|
78
|
+
if (concept.profile) lines.push(`profile: ${formatScalar(concept.profile)}`);
|
|
79
|
+
|
|
80
|
+
const fm = concept.frontmatter;
|
|
81
|
+
if (typeof fm.digitalSourceType === "string" && fm.digitalSourceType.length > 0) {
|
|
82
|
+
lines.push(`digitalSourceType: ${formatScalar(fm.digitalSourceType)}`);
|
|
83
|
+
}
|
|
84
|
+
if (typeof fm.euAiLabel === "string" && fm.euAiLabel.length > 0) {
|
|
85
|
+
lines.push(`euAiLabel: ${formatScalar(fm.euAiLabel)}`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof fm.aiDisclosureNote === "string" && fm.aiDisclosureNote.length > 0) {
|
|
88
|
+
lines.push(`aiDisclosureNote: ${formatScalar(fm.aiDisclosureNote)}`);
|
|
89
|
+
}
|
|
90
|
+
if (Array.isArray(fm.aiSystems) && fm.aiSystems.length > 0) {
|
|
91
|
+
const parsed = fm.aiSystems.filter(
|
|
92
|
+
(s): s is { name: string; version?: string; provider?: string } =>
|
|
93
|
+
s !== null &&
|
|
94
|
+
typeof s === "object" &&
|
|
95
|
+
!Array.isArray(s) &&
|
|
96
|
+
typeof (s as { name?: unknown }).name === "string",
|
|
97
|
+
);
|
|
98
|
+
if (parsed.length > 0) appendAiSystemsEntry(lines, "aiSystems", parsed);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ordered = new Set(KEY_ORDER);
|
|
102
|
+
const restKeys = Object.keys(concept.frontmatter)
|
|
103
|
+
.filter((k) => !ordered.has(k as (typeof KEY_ORDER)[number]))
|
|
104
|
+
.sort();
|
|
105
|
+
for (const key of restKeys) {
|
|
106
|
+
appendYamlEntry(lines, key, concept.frontmatter[key]);
|
|
107
|
+
}
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Document → OKF native markdown。 */
|
|
112
|
+
export function conceptToOkfMarkdown(concept: OkfConcept): string {
|
|
113
|
+
const lines = toOkfFrontmatterLines(concept);
|
|
114
|
+
return `---\n${lines.join("\n")}\n---\n${concept.body}`;
|
|
115
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import Ajv from "ajv";
|
|
2
|
+
import addFormats from "ajv-formats";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { validateDisclosureFields } from "./digital-source-type.ts";
|
|
7
|
+
import { extract } from "./extract.ts";
|
|
8
|
+
import { normalizeConcept } from "./normalize.ts";
|
|
9
|
+
import { parseYaml } from "./yaml.ts";
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const REPO_ROOT = join(__dirname, "../../..");
|
|
13
|
+
const PROFILE_SCHEMA_DIR = join(REPO_ROOT, "profile");
|
|
14
|
+
|
|
15
|
+
const SUPPORTED_PROFILE_RE = /^sorane-okf\/(0\.[12])$/;
|
|
16
|
+
const DEFAULT_PROFILE = "sorane-okf/0.1";
|
|
17
|
+
|
|
18
|
+
const SUPPORTED_TYPES = new Set(["article", "index"]);
|
|
19
|
+
|
|
20
|
+
export interface ValidationIssue {
|
|
21
|
+
readonly where: "structure" | "frontmatter" | "type" | "profile";
|
|
22
|
+
readonly message: string;
|
|
23
|
+
readonly instancePath?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ValidationResult {
|
|
27
|
+
readonly file: string;
|
|
28
|
+
readonly ok: boolean;
|
|
29
|
+
readonly type?: string;
|
|
30
|
+
readonly issues: readonly ValidationIssue[];
|
|
31
|
+
readonly warnings: readonly string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const validatorCache = new Map<string, ReturnType<Ajv["compile"]>>();
|
|
35
|
+
|
|
36
|
+
export function validateProfileFormat(
|
|
37
|
+
profile: string | undefined,
|
|
38
|
+
): ValidationIssue | null {
|
|
39
|
+
if (profile === undefined) return null;
|
|
40
|
+
if (!SUPPORTED_PROFILE_RE.test(profile)) {
|
|
41
|
+
return {
|
|
42
|
+
where: "profile",
|
|
43
|
+
message: `Unsupported profile "${profile}"; supported: sorane-okf/0.1, sorane-okf/0.2`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveProfileSchema(profile: string): string {
|
|
50
|
+
const m = profile.match(SUPPORTED_PROFILE_RE);
|
|
51
|
+
if (!m) {
|
|
52
|
+
throw new Error(`unsupported profile: ${profile}`);
|
|
53
|
+
}
|
|
54
|
+
return join(PROFILE_SCHEMA_DIR, `sorane-okf-${m[1]}.schema.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getValidatorForProfile(profile: string): ReturnType<Ajv["compile"]> {
|
|
58
|
+
const path = resolveProfileSchema(profile);
|
|
59
|
+
let v = validatorCache.get(path);
|
|
60
|
+
if (!v) {
|
|
61
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
62
|
+
addFormats(ajv);
|
|
63
|
+
v = ajv.compile(JSON.parse(readFileSync(path, "utf8")));
|
|
64
|
+
validatorCache.set(path, v);
|
|
65
|
+
}
|
|
66
|
+
return v;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function slugFromPath(filePath: string): string {
|
|
70
|
+
const base = filePath.replace(/\\/g, "/").split("/").pop() ?? filePath;
|
|
71
|
+
return base.replace(/\.md$/i, "");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 1 つの markdown ソースを sorane-okf プロファイルで検証する。 */
|
|
75
|
+
export function validateSource(file: string, source: string): ValidationResult {
|
|
76
|
+
const { frontmatter, body } = extract(source);
|
|
77
|
+
const issues: ValidationIssue[] = [];
|
|
78
|
+
const warnings: string[] = [];
|
|
79
|
+
|
|
80
|
+
if (frontmatter === null) {
|
|
81
|
+
return {
|
|
82
|
+
file,
|
|
83
|
+
ok: false,
|
|
84
|
+
issues: [
|
|
85
|
+
{
|
|
86
|
+
where: "structure",
|
|
87
|
+
message: "frontmatter ブロック(--- で囲む)がありません",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
warnings,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let raw: Record<string, unknown>;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = parseYaml(frontmatter);
|
|
97
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
98
|
+
return {
|
|
99
|
+
file,
|
|
100
|
+
ok: false,
|
|
101
|
+
issues: [{ where: "frontmatter", message: "frontmatter が YAML マッピングではありません" }],
|
|
102
|
+
warnings,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
raw = parsed as Record<string, unknown>;
|
|
106
|
+
} catch (e) {
|
|
107
|
+
return {
|
|
108
|
+
file,
|
|
109
|
+
ok: false,
|
|
110
|
+
issues: [
|
|
111
|
+
{
|
|
112
|
+
where: "frontmatter",
|
|
113
|
+
message: `frontmatter の YAML 解析に失敗: ${e instanceof Error ? e.message : String(e)}`,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
warnings,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const profileIssue = validateProfileFormat(
|
|
121
|
+
typeof raw.profile === "string" ? raw.profile : undefined,
|
|
122
|
+
);
|
|
123
|
+
if (profileIssue) {
|
|
124
|
+
issues.push(profileIssue);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const concept = normalizeConcept(raw, body, slugFromPath(file));
|
|
128
|
+
warnings.push(...concept.warnings);
|
|
129
|
+
|
|
130
|
+
if (!concept.type) {
|
|
131
|
+
issues.push({
|
|
132
|
+
where: "type",
|
|
133
|
+
message: "OKF 必須フィールド `type` がありません",
|
|
134
|
+
});
|
|
135
|
+
} else if (!SUPPORTED_TYPES.has(concept.type)) {
|
|
136
|
+
issues.push({
|
|
137
|
+
where: "type",
|
|
138
|
+
message: `未サポートの type: ${concept.type}(v0.1 は article / index のみ)`,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const profile =
|
|
143
|
+
typeof concept.profile === "string" && SUPPORTED_PROFILE_RE.test(concept.profile)
|
|
144
|
+
? concept.profile
|
|
145
|
+
: DEFAULT_PROFILE;
|
|
146
|
+
|
|
147
|
+
if (issues.every((i) => i.where !== "profile")) {
|
|
148
|
+
const validate = getValidatorForProfile(profile);
|
|
149
|
+
const fmForSchema: Record<string, unknown> = {
|
|
150
|
+
type: concept.type || "article",
|
|
151
|
+
title: concept.title,
|
|
152
|
+
...concept.frontmatter,
|
|
153
|
+
};
|
|
154
|
+
if (concept.timestamp) fmForSchema.timestamp = concept.timestamp;
|
|
155
|
+
if (concept.description) fmForSchema.description = concept.description;
|
|
156
|
+
if (concept.tags) fmForSchema.tags = [...concept.tags];
|
|
157
|
+
if (concept.resource) fmForSchema.resource = concept.resource;
|
|
158
|
+
if (concept.profile) fmForSchema.profile = concept.profile;
|
|
159
|
+
|
|
160
|
+
if (!validate(fmForSchema)) {
|
|
161
|
+
for (const err of validate.errors ?? []) {
|
|
162
|
+
issues.push({
|
|
163
|
+
where: "frontmatter",
|
|
164
|
+
instancePath: err.instancePath || "/",
|
|
165
|
+
message: err.message ?? "invalid",
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const strictDisclosure = profile === "sorane-okf/0.2";
|
|
172
|
+
const disclosure = validateDisclosureFields(concept.frontmatter, strictDisclosure);
|
|
173
|
+
warnings.push(...disclosure.warnings);
|
|
174
|
+
for (const d of disclosure.issues) {
|
|
175
|
+
issues.push({
|
|
176
|
+
where: "frontmatter",
|
|
177
|
+
instancePath: `/${d.path}`,
|
|
178
|
+
message: d.message,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
file,
|
|
184
|
+
ok: issues.length === 0,
|
|
185
|
+
type: concept.type || undefined,
|
|
186
|
+
issues,
|
|
187
|
+
warnings,
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/yaml.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import yaml from "js-yaml";
|
|
2
|
+
|
|
3
|
+
/** CORE_SCHEMA で YAML を読む(日付の自動 Date 化を防ぐ)。 */
|
|
4
|
+
export function parseYaml(source: string): unknown {
|
|
5
|
+
return yaml.load(source, { schema: yaml.CORE_SCHEMA });
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function dumpYaml(value: unknown): string {
|
|
9
|
+
return yaml.dump(value, {
|
|
10
|
+
schema: yaml.CORE_SCHEMA,
|
|
11
|
+
lineWidth: -1,
|
|
12
|
+
quotingType: '"',
|
|
13
|
+
forceQuotes: false,
|
|
14
|
+
noRefs: true,
|
|
15
|
+
});
|
|
16
|
+
}
|