nebula-cms 0.1.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/.claude/settings.local.json +42 -0
- package/.github/workflows/ci.yml +31 -0
- package/.mcp.json +12 -0
- package/.prettierignore +5 -0
- package/.prettierrc.cjs +22 -0
- package/AGENTS.md +183 -0
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/package.json +74 -0
- package/playground/.claude/settings.local.json +5 -0
- package/playground/astro.config.mjs +7 -0
- package/playground/node_modules/.bin/astro +21 -0
- package/playground/node_modules/.bin/rollup +21 -0
- package/playground/node_modules/.bin/tsc +21 -0
- package/playground/node_modules/.bin/tsserver +21 -0
- package/playground/node_modules/.bin/vite +21 -0
- package/playground/node_modules/.vite/_svelte_metadata.json +1 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js +80 -0
- package/playground/node_modules/.vite/deps/@astrojs_svelte_client__js.js.map +7 -0
- package/playground/node_modules/.vite/deps/_metadata.json +184 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js +6776 -0
- package/playground/node_modules/.vite/deps/astro___aria-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js +3754 -0
- package/playground/node_modules/.vite/deps/astro___axobject-query.js.map +7 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js +34 -0
- package/playground/node_modules/.vite/deps/astro___html-escaper.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js +0 -0
- package/playground/node_modules/.vite/deps/chunk-AJXJMYAF.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js +1005 -0
- package/playground/node_modules/.vite/deps/chunk-ALJIOON6.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js +8 -0
- package/playground/node_modules/.vite/deps/chunk-BUSYA2B4.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js +21 -0
- package/playground/node_modules/.vite/deps/chunk-CNYJBM5F.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js +223 -0
- package/playground/node_modules/.vite/deps/chunk-DBPNBGEI.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js +204 -0
- package/playground/node_modules/.vite/deps/chunk-G3C2FXJT.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js +27 -0
- package/playground/node_modules/.vite/deps/chunk-GKDKFWC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js +4376 -0
- package/playground/node_modules/.vite/deps/chunk-HNCLEOC5.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js +688 -0
- package/playground/node_modules/.vite/deps/chunk-JICYXBFU.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js +5099 -0
- package/playground/node_modules/.vite/deps/chunk-KCUTL6DD.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js +23 -0
- package/playground/node_modules/.vite/deps/chunk-ZP4UNCSN.js.map +7 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js +148 -0
- package/playground/node_modules/.vite/deps/chunk-ZREFNRZZ.js.map +7 -0
- package/playground/node_modules/.vite/deps/package.json +3 -0
- package/playground/node_modules/.vite/deps/smol-toml.js +843 -0
- package/playground/node_modules/.vite/deps/smol-toml.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte.js +55 -0
- package/playground/node_modules/.vite/deps/svelte.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js +9 -0
- package/playground/node_modules/.vite/deps/svelte___clsx.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js +57 -0
- package/playground/node_modules/.vite/deps/svelte_animate.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js +15 -0
- package/playground/node_modules/.vite/deps/svelte_attachments.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js +67 -0
- package/playground/node_modules/.vite/deps/svelte_easing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_events.js +11 -0
- package/playground/node_modules/.vite/deps/svelte_events.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js +5 -0
- package/playground/node_modules/.vite/deps/svelte_internal.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js +402 -0
- package/playground/node_modules/.vite/deps/svelte_internal_client.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js +10 -0
- package/playground/node_modules/.vite/deps/svelte_internal_disclose-version.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_async.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js +8 -0
- package/playground/node_modules/.vite/deps/svelte_internal_flags_tracing.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js +35 -0
- package/playground/node_modules/.vite/deps/svelte_legacy.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js +545 -0
- package/playground/node_modules/.vite/deps/svelte_motion.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js +29 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js +127 -0
- package/playground/node_modules/.vite/deps/svelte_reactivity_window.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_store.js +103 -0
- package/playground/node_modules/.vite/deps/svelte_store.js.map +7 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js +208 -0
- package/playground/node_modules/.vite/deps/svelte_transition.js.map +7 -0
- package/playground/package.json +16 -0
- package/playground/pnpm-lock.yaml +3167 -0
- package/playground/src/content/authors/jane-doe.json +8 -0
- package/playground/src/content/config/build.toml +2 -0
- package/playground/src/content/courses/web-fundamentals.json +29 -0
- package/playground/src/content/docs/advanced.mdx +6 -0
- package/playground/src/content/docs/intro.md +6 -0
- package/playground/src/content/guides/getting-started.mdx +6 -0
- package/playground/src/content/posts/hello-world.md +7 -0
- package/playground/src/content/products/t-shirt.json +16 -0
- package/playground/src/content/recipes/pancakes.mdoc +8 -0
- package/playground/src/content/settings/site.yml +2 -0
- package/playground/src/content.config.ts +198 -0
- package/playground/src/env.d.ts +1 -0
- package/playground/src/pages/index.astro +11 -0
- package/playground/src/pages/nebula.astro +14 -0
- package/pnpm-workspace.yaml +2 -0
- package/scripts/subset-icons.mjs +178 -0
- package/src/astro/index.ts +295 -0
- package/src/client/Admin.svelte +283 -0
- package/src/client/components/BackendPicker.svelte +291 -0
- package/src/client/components/DraftChip.svelte +46 -0
- package/src/client/components/MetadataForm.svelte +56 -0
- package/src/client/components/ThemeToggle.svelte +18 -0
- package/src/client/components/dialogs/DeleteDraftDialog.svelte +51 -0
- package/src/client/components/dialogs/FilenameDialog.svelte +129 -0
- package/src/client/components/editor/EditorPane.svelte +227 -0
- package/src/client/components/editor/EditorTabs.svelte +81 -0
- package/src/client/components/editor/EditorToolbar.svelte +131 -0
- package/src/client/components/editor/FormatSelector.svelte +66 -0
- package/src/client/components/editor/Toolbar.svelte +17 -0
- package/src/client/components/fields/ArrayField.svelte +339 -0
- package/src/client/components/fields/ArrayItem.svelte +325 -0
- package/src/client/components/fields/BooleanField.svelte +114 -0
- package/src/client/components/fields/DateField.svelte +82 -0
- package/src/client/components/fields/EnumField.svelte +74 -0
- package/src/client/components/fields/FieldWrapper.svelte +96 -0
- package/src/client/components/fields/NumberField.svelte +99 -0
- package/src/client/components/fields/ObjectField.svelte +121 -0
- package/src/client/components/fields/SchemaField.svelte +107 -0
- package/src/client/components/fields/StringField.svelte +104 -0
- package/src/client/components/sidebar/AdminSidebar.svelte +339 -0
- package/src/client/components/sidebar/AdminSidebarSort.svelte +123 -0
- package/src/client/css/a11y.css +14 -0
- package/src/client/css/btn.css +113 -0
- package/src/client/css/dialog.css +29 -0
- package/src/client/css/field-input.css +39 -0
- package/src/client/css/reset.css +59 -0
- package/src/client/css/theme.css +77 -0
- package/src/client/index.ts +1 -0
- package/src/client/js/drafts/merge.svelte.ts +121 -0
- package/src/client/js/drafts/ops.svelte.ts +227 -0
- package/src/client/js/drafts/storage.ts +108 -0
- package/src/client/js/drafts/workers/diff.ts +40 -0
- package/src/client/js/editor/editor.svelte.ts +343 -0
- package/src/client/js/editor/languages.ts +98 -0
- package/src/client/js/editor/link-wrap.ts +45 -0
- package/src/client/js/editor/markdown-shortcuts.ts +261 -0
- package/src/client/js/handlers/admin.ts +246 -0
- package/src/client/js/state/dialogs.svelte.ts +35 -0
- package/src/client/js/state/router.svelte.ts +156 -0
- package/src/client/js/state/schema.svelte.ts +140 -0
- package/src/client/js/state/state.svelte.ts +334 -0
- package/src/client/js/state/theme.svelte.ts +173 -0
- package/src/client/js/storage/adapter.ts +102 -0
- package/src/client/js/storage/client.ts +150 -0
- package/src/client/js/storage/db.ts +36 -0
- package/src/client/js/storage/fsa.ts +110 -0
- package/src/client/js/storage/github.ts +297 -0
- package/src/client/js/storage/storage.ts +83 -0
- package/src/client/js/storage/workers/frontmatter.ts +320 -0
- package/src/client/js/storage/workers/storage.ts +177 -0
- package/src/client/js/storage/workers/toml-parser.ts +106 -0
- package/src/client/js/storage/workers/yaml-parser.ts +132 -0
- package/src/client/js/utils/file-types.ts +192 -0
- package/src/client/js/utils/format.ts +16 -0
- package/src/client/js/utils/frontmatter.ts +38 -0
- package/src/client/js/utils/schema-utils.ts +295 -0
- package/src/client/js/utils/slug.ts +18 -0
- package/src/client/js/utils/sort.ts +84 -0
- package/src/client/js/utils/stable-stringify.ts +27 -0
- package/src/client/js/utils/url-utils.ts +38 -0
- package/src/types.ts +25 -0
- package/src/virtual.d.ts +22 -0
- package/svelte.config.js +4 -0
- package/tests/astro/build.test.ts +63 -0
- package/tests/astro/index.test.ts +689 -0
- package/tests/client/components/Admin.test.ts +446 -0
- package/tests/client/components/BackendPicker.test.ts +239 -0
- package/tests/client/components/DraftChip.test.ts +53 -0
- package/tests/client/components/MetadataForm.test.ts +164 -0
- package/tests/client/components/dialogs/DeleteDraftDialog.test.ts +91 -0
- package/tests/client/components/dialogs/FilenameDialog.test.ts +209 -0
- package/tests/client/components/dialogs/dialog-stubs.ts +19 -0
- package/tests/client/components/editor/EditorPane.test.ts +100 -0
- package/tests/client/components/editor/EditorTabs.test.ts +253 -0
- package/tests/client/components/editor/EditorToolbar.test.ts +252 -0
- package/tests/client/components/editor/fixtures.ts +31 -0
- package/tests/client/components/fields/ArrayField.test.ts +197 -0
- package/tests/client/components/fields/BooleanField.test.ts +206 -0
- package/tests/client/components/fields/DateField.test.ts +210 -0
- package/tests/client/components/fields/EnumField.test.ts +246 -0
- package/tests/client/components/fields/NumberField.test.ts +240 -0
- package/tests/client/components/fields/ObjectField.test.ts +157 -0
- package/tests/client/components/fields/SchemaField.test.ts +190 -0
- package/tests/client/components/fields/StringField.test.ts +223 -0
- package/tests/client/components/sidebar/AdminSidebar.test.ts +285 -0
- package/tests/client/components/sidebar/AdminSidebarSort.test.ts +135 -0
- package/tests/client/components/sidebar/sort-mock.ts +23 -0
- package/tests/client/js/drafts/fixtures.ts +22 -0
- package/tests/client/js/drafts/merge.test.ts +282 -0
- package/tests/client/js/drafts/ops.test.ts +658 -0
- package/tests/client/js/drafts/storage.test.ts +200 -0
- package/tests/client/js/drafts/workers/diff.test.ts +165 -0
- package/tests/client/js/editor/editor.test.ts +616 -0
- package/tests/client/js/editor/link-wrap.test.ts +225 -0
- package/tests/client/js/editor/markdown-shortcuts.test.ts +370 -0
- package/tests/client/js/handlers/admin.test.ts +467 -0
- package/tests/client/js/state/router.test.ts +619 -0
- package/tests/client/js/state/schema.test.ts +266 -0
- package/tests/client/js/state/state.test.ts +328 -0
- package/tests/client/js/storage/adapter.test.ts +115 -0
- package/tests/client/js/storage/client.test.ts +250 -0
- package/tests/client/js/storage/db.test.ts +59 -0
- package/tests/client/js/storage/fsa.test.ts +284 -0
- package/tests/client/js/storage/github.test.ts +349 -0
- package/tests/client/js/storage/mock-port.ts +95 -0
- package/tests/client/js/storage/storage.test.ts +77 -0
- package/tests/client/js/storage/workers/frontmatter.test.ts +479 -0
- package/tests/client/js/storage/workers/storage.test.ts +299 -0
- package/tests/client/js/storage/workers/toml-parser.test.ts +169 -0
- package/tests/client/js/storage/workers/yaml-parser.test.ts +168 -0
- package/tests/client/js/utils/file-types.test.ts +268 -0
- package/tests/client/js/utils/frontmatter.test.ts +87 -0
- package/tests/client/js/utils/schema-utils.test.ts +318 -0
- package/tests/client/js/utils/slug.test.ts +58 -0
- package/tests/client/js/utils/sort.test.ts +276 -0
- package/tests/client/js/utils/stable-stringify.test.ts +68 -0
- package/tests/client/js/utils/url-utils.test.ts +70 -0
- package/tests/e2e/backend-connection.test.ts +301 -0
- package/tests/e2e/draft-lifecycle.test.ts +388 -0
- package/tests/e2e/editing.test.ts +355 -0
- package/tests/e2e/github-adapter.test.ts +330 -0
- package/tests/e2e/helpers/mock-adapter.ts +166 -0
- package/tests/e2e/helpers/test-app.ts +155 -0
- package/tests/e2e/navigation.test.ts +358 -0
- package/tests/e2e/publishing.test.ts +345 -0
- package/tests/e2e/unsaved-changes.test.ts +317 -0
- package/tests/setup.ts +2 -0
- package/tests/stubs/codemirror.ts +197 -0
- package/tsconfig.json +19 -0
- package/vitest.config.ts +178 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* YAML Parser Worker
|
|
3
|
+
*
|
|
4
|
+
* Handles YAML parsing and serialization on behalf of the main thread.
|
|
5
|
+
* Messages are dispatched by type: 'parse', 'parse-batch', 'stringify'.
|
|
6
|
+
* Each handler wraps its logic in try/catch and always posts a typed result.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { load, dump } from 'js-yaml';
|
|
10
|
+
|
|
11
|
+
// Inbound message shape for a single YAML parse request.
|
|
12
|
+
interface ParseMessage {
|
|
13
|
+
type: 'parse';
|
|
14
|
+
id: string;
|
|
15
|
+
content: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A single item in a batch parse request.
|
|
19
|
+
interface BatchItem {
|
|
20
|
+
key: string;
|
|
21
|
+
content: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Inbound message shape for a batch YAML parse request.
|
|
25
|
+
interface ParseBatchMessage {
|
|
26
|
+
type: 'parse-batch';
|
|
27
|
+
id: string;
|
|
28
|
+
items: BatchItem[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Inbound message shape for a YAML stringify request.
|
|
32
|
+
interface StringifyMessage {
|
|
33
|
+
type: 'stringify';
|
|
34
|
+
id: string;
|
|
35
|
+
data: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Union of all inbound message types.
|
|
39
|
+
type InboundMessage = ParseMessage | ParseBatchMessage | StringifyMessage;
|
|
40
|
+
|
|
41
|
+
/*
|
|
42
|
+
//////////////////////////////
|
|
43
|
+
// Message handler
|
|
44
|
+
//////////////////////////////
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handles a single YAML parse request. Parses the provided content string
|
|
49
|
+
* and posts a parse-result message with the resulting data object or error.
|
|
50
|
+
* @param {ParseMessage} msg - The inbound parse message
|
|
51
|
+
* @return {void}
|
|
52
|
+
*/
|
|
53
|
+
function handleParse(msg: ParseMessage): void {
|
|
54
|
+
try {
|
|
55
|
+
const data = load(msg.content) as Record<string, unknown>;
|
|
56
|
+
self.postMessage({ type: 'parse-result', id: msg.id, ok: true, data });
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
59
|
+
self.postMessage({ type: 'parse-result', id: msg.id, ok: false, error });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Handles a batch YAML parse request. Iterates over all items, parses each
|
|
65
|
+
* one, and posts a parse-batch-result with a key-to-data results map.
|
|
66
|
+
* If any item fails to parse, the entire batch result is marked as failed.
|
|
67
|
+
* @param {ParseBatchMessage} msg - The inbound parse-batch message
|
|
68
|
+
* @return {void}
|
|
69
|
+
*/
|
|
70
|
+
function handleParseBatch(msg: ParseBatchMessage): void {
|
|
71
|
+
try {
|
|
72
|
+
const results: Record<string, Record<string, unknown>> = {};
|
|
73
|
+
for (const item of msg.items) {
|
|
74
|
+
results[item.key] = load(item.content) as Record<string, unknown>;
|
|
75
|
+
}
|
|
76
|
+
self.postMessage({
|
|
77
|
+
type: 'parse-batch-result',
|
|
78
|
+
id: msg.id,
|
|
79
|
+
ok: true,
|
|
80
|
+
results,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
84
|
+
self.postMessage({
|
|
85
|
+
type: 'parse-batch-result',
|
|
86
|
+
id: msg.id,
|
|
87
|
+
ok: false,
|
|
88
|
+
error,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Handles a YAML stringify request. Serializes the provided data object to
|
|
95
|
+
* a YAML string and posts a stringify-result message with the content.
|
|
96
|
+
* lineWidth: -1 disables js-yaml's automatic line folding so long values
|
|
97
|
+
* are not wrapped across lines, which would corrupt multi-line string values.
|
|
98
|
+
* @param {StringifyMessage} msg - The inbound stringify message
|
|
99
|
+
* @return {void}
|
|
100
|
+
*/
|
|
101
|
+
function handleStringify(msg: StringifyMessage): void {
|
|
102
|
+
try {
|
|
103
|
+
const content = dump(msg.data, { lineWidth: -1 });
|
|
104
|
+
self.postMessage({
|
|
105
|
+
type: 'stringify-result',
|
|
106
|
+
id: msg.id,
|
|
107
|
+
ok: true,
|
|
108
|
+
content,
|
|
109
|
+
});
|
|
110
|
+
} catch (err) {
|
|
111
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
112
|
+
self.postMessage({
|
|
113
|
+
type: 'stringify-result',
|
|
114
|
+
id: msg.id,
|
|
115
|
+
ok: false,
|
|
116
|
+
error,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Listen for messages from the main thread and dispatch by type
|
|
122
|
+
self.addEventListener('message', (event: MessageEvent<InboundMessage>) => {
|
|
123
|
+
const msg = event.data;
|
|
124
|
+
|
|
125
|
+
if (msg.type === 'parse') {
|
|
126
|
+
handleParse(msg);
|
|
127
|
+
} else if (msg.type === 'parse-batch') {
|
|
128
|
+
handleParseBatch(msg);
|
|
129
|
+
} else if (msg.type === 'stringify') {
|
|
130
|
+
handleStringify(msg);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* File Type Registry
|
|
3
|
+
* Central source of truth for supported file formats. All other modules
|
|
4
|
+
* (storage adapters, orchestrator worker, editor, publish handler) derive
|
|
5
|
+
* extension lists, category, and serialization format from this registry.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// Configuration for a single supported file type.
|
|
9
|
+
export type FileTypeConfig = {
|
|
10
|
+
// All file extensions associated with this type, first entry is the default.
|
|
11
|
+
extensions: string[];
|
|
12
|
+
// Whether this type has a body editor (markdown/MDX/Markdoc).
|
|
13
|
+
hasBody: boolean;
|
|
14
|
+
// Whether the file holds frontmatter+body or pure data.
|
|
15
|
+
category: 'frontmatter' | 'data';
|
|
16
|
+
// For data files, which serialization format to use.
|
|
17
|
+
dataFormat?: 'json' | 'yaml' | 'toml';
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Registry mapping type identifiers (as used in schema `files` arrays) to their config.
|
|
21
|
+
export const FILE_TYPES: Record<string, FileTypeConfig> = {
|
|
22
|
+
md: {
|
|
23
|
+
extensions: ['.md', '.markdown'],
|
|
24
|
+
hasBody: true,
|
|
25
|
+
category: 'frontmatter',
|
|
26
|
+
},
|
|
27
|
+
mdx: {
|
|
28
|
+
extensions: ['.mdx'],
|
|
29
|
+
hasBody: true,
|
|
30
|
+
category: 'frontmatter',
|
|
31
|
+
},
|
|
32
|
+
markdoc: {
|
|
33
|
+
extensions: ['.mdoc', '.markdoc'],
|
|
34
|
+
hasBody: true,
|
|
35
|
+
category: 'frontmatter',
|
|
36
|
+
},
|
|
37
|
+
json: {
|
|
38
|
+
extensions: ['.json'],
|
|
39
|
+
hasBody: false,
|
|
40
|
+
category: 'data',
|
|
41
|
+
dataFormat: 'json',
|
|
42
|
+
},
|
|
43
|
+
yaml: {
|
|
44
|
+
extensions: ['.yml', '.yaml'],
|
|
45
|
+
hasBody: false,
|
|
46
|
+
category: 'data',
|
|
47
|
+
dataFormat: 'yaml',
|
|
48
|
+
},
|
|
49
|
+
toml: {
|
|
50
|
+
extensions: ['.toml'],
|
|
51
|
+
hasBody: false,
|
|
52
|
+
category: 'data',
|
|
53
|
+
dataFormat: 'toml',
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
//////////////////////////////
|
|
59
|
+
// Extension reverse-lookup map
|
|
60
|
+
// Built once at module load for O(1) extension-to-config lookups.
|
|
61
|
+
//////////////////////////////
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
// Maps each known extension to its FileTypeConfig.
|
|
65
|
+
const extensionMap = new Map<string, FileTypeConfig>();
|
|
66
|
+
|
|
67
|
+
// Maps each known extension to its type identifier (e.g. '.md' -> 'md').
|
|
68
|
+
const extensionToTypeID = new Map<string, string>();
|
|
69
|
+
|
|
70
|
+
for (const [typeId, config] of Object.entries(FILE_TYPES)) {
|
|
71
|
+
for (const ext of config.extensions) {
|
|
72
|
+
extensionMap.set(ext, config);
|
|
73
|
+
extensionToTypeID.set(ext, typeId);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/*
|
|
78
|
+
//////////////////////////////
|
|
79
|
+
// Helper: extract extension
|
|
80
|
+
//////////////////////////////
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Extracts the last dot-prefixed extension from a filename, or an empty string if none.
|
|
85
|
+
* @param {string} filename - The filename to extract the extension from
|
|
86
|
+
* @return {string} The extension including the leading dot (e.g. '.md'), or ''
|
|
87
|
+
*/
|
|
88
|
+
function getExtension(filename: string): string {
|
|
89
|
+
const idx = filename.lastIndexOf('.');
|
|
90
|
+
if (idx === -1) return '';
|
|
91
|
+
return filename.slice(idx);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
//////////////////////////////
|
|
96
|
+
// Exported helpers
|
|
97
|
+
//////////////////////////////
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Resolves a schema's `files` array of type identifiers to a flat list of file extensions.
|
|
102
|
+
* Used by storage adapters for file discovery filtering.
|
|
103
|
+
* @param {Record<string, unknown>} schema - A collection JSON Schema with an optional `files` array
|
|
104
|
+
* @return {string[]} Ordered list of extensions (e.g. ['.md', '.markdown', '.json'])
|
|
105
|
+
*/
|
|
106
|
+
export function getExtensionsForSchema(
|
|
107
|
+
schema: Record<string, unknown>,
|
|
108
|
+
): string[] {
|
|
109
|
+
const files = schema['files'];
|
|
110
|
+
if (!Array.isArray(files)) return [];
|
|
111
|
+
|
|
112
|
+
const extensions: string[] = [];
|
|
113
|
+
for (const typeId of files as string[]) {
|
|
114
|
+
const config = FILE_TYPES[typeId];
|
|
115
|
+
if (config) {
|
|
116
|
+
extensions.push(...config.extensions);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return extensions;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns whether a file should show the body editor panel.
|
|
124
|
+
* True for markdown, MDX, and Markdoc files; false for pure data files.
|
|
125
|
+
* @param {string} filename - The filename to check
|
|
126
|
+
* @return {boolean} True if the file type has a body editor
|
|
127
|
+
*/
|
|
128
|
+
export function hasBodyEditor(filename: string): boolean {
|
|
129
|
+
const config = extensionMap.get(getExtension(filename));
|
|
130
|
+
return config?.hasBody ?? false;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Returns the category of a file based on its extension.
|
|
135
|
+
* Used to determine whether to render frontmatter fields or data-only fields.
|
|
136
|
+
* @param {string} filename - The filename to categorise
|
|
137
|
+
* @return {'frontmatter' | 'data' | null} The file category, or null for unrecognised extensions
|
|
138
|
+
*/
|
|
139
|
+
export function getFileCategory(
|
|
140
|
+
filename: string,
|
|
141
|
+
): 'frontmatter' | 'data' | null {
|
|
142
|
+
const config = extensionMap.get(getExtension(filename));
|
|
143
|
+
return config?.category ?? null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Returns the serialization format for a data file.
|
|
148
|
+
* Returns null for frontmatter files and unrecognised extensions.
|
|
149
|
+
* @param {string} filename - The filename to inspect
|
|
150
|
+
* @return {'json' | 'yaml' | 'toml' | null} The data format, or null if not a data file
|
|
151
|
+
*/
|
|
152
|
+
export function getDataFormat(
|
|
153
|
+
filename: string,
|
|
154
|
+
): 'json' | 'yaml' | 'toml' | null {
|
|
155
|
+
const config = extensionMap.get(getExtension(filename));
|
|
156
|
+
return config?.dataFormat ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Strips the file extension from a filename when the extension is a known type.
|
|
161
|
+
* Returns the filename unchanged if the extension is not recognised.
|
|
162
|
+
* Used for generating URL slugs from filenames.
|
|
163
|
+
* @param {string} filename - The filename to strip the extension from
|
|
164
|
+
* @return {string} The filename without its known extension, or the original filename
|
|
165
|
+
*/
|
|
166
|
+
export function stripExtension(filename: string): string {
|
|
167
|
+
const ext = getExtension(filename);
|
|
168
|
+
if (ext && extensionMap.has(ext)) {
|
|
169
|
+
return filename.slice(0, filename.length - ext.length);
|
|
170
|
+
}
|
|
171
|
+
return filename;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Returns the default (first) file extension for a given type identifier.
|
|
176
|
+
* Used when creating new files to pick the canonical extension for a format.
|
|
177
|
+
* @param {string} type - A type identifier (e.g. 'md', 'yaml', 'toml')
|
|
178
|
+
* @return {string | null} The default extension including the leading dot, or null for unknown types
|
|
179
|
+
*/
|
|
180
|
+
export function getDefaultExtension(type: string): string | null {
|
|
181
|
+
return FILE_TYPES[type]?.extensions[0] ?? null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Returns the type identifier for a given filename by looking up its extension.
|
|
186
|
+
* Used when the active file's type must be determined for the format selector.
|
|
187
|
+
* @param {string} filename - The filename to look up
|
|
188
|
+
* @return {string | null} The type identifier (e.g. 'md', 'yaml'), or null for unrecognised extensions
|
|
189
|
+
*/
|
|
190
|
+
export function getTypeForFilename(filename: string): string | null {
|
|
191
|
+
return extensionToTypeID.get(getExtension(filename)) ?? null;
|
|
192
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Display formatting utilities for property names and labels.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Converts a property name string to Title Case for display in form labels.
|
|
7
|
+
* Splits on camelCase boundaries, hyphens, and underscores, then capitalizes each word.
|
|
8
|
+
* @param {string} str - The raw property name to convert (e.g., "firstName", "last-name", "zip_code")
|
|
9
|
+
* @return {string} The title-cased display string (e.g., "First Name", "Last Name", "Zip Code")
|
|
10
|
+
*/
|
|
11
|
+
export function toTitleCase(str: string): string {
|
|
12
|
+
return str
|
|
13
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
14
|
+
.replace(/[-_]/g, ' ')
|
|
15
|
+
.replace(/\b\w/g, (c) => c.toUpperCase());
|
|
16
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Result of splitting a file into frontmatter and body.
|
|
2
|
+
export type SplitResult = {
|
|
3
|
+
rawFrontmatter: string;
|
|
4
|
+
body: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Splits a markdown/MDX file into raw YAML frontmatter and body content.
|
|
9
|
+
* Handles BOM stripping, CRLF normalization, and horizontal rule rejection.
|
|
10
|
+
* @param {string} content - Raw file content
|
|
11
|
+
* @return {SplitResult} The separated frontmatter and body strings
|
|
12
|
+
*/
|
|
13
|
+
export function splitFrontmatter(content: string): SplitResult {
|
|
14
|
+
let str = content.startsWith('\uFEFF') ? content.slice(1) : content;
|
|
15
|
+
str = str.replace(/\r\n/g, '\n');
|
|
16
|
+
|
|
17
|
+
// Reject horizontal rules (----) and content not starting with frontmatter delimiter
|
|
18
|
+
if (str.startsWith('----') || !str.startsWith('---\n')) {
|
|
19
|
+
return { rawFrontmatter: '', body: str };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const closeIndex = str.indexOf('\n---\n', 3);
|
|
23
|
+
if (closeIndex === -1) {
|
|
24
|
+
// Check for --- at end of file with no trailing newline
|
|
25
|
+
if (str.endsWith('\n---')) {
|
|
26
|
+
return {
|
|
27
|
+
rawFrontmatter: str.slice(4, str.length - 4),
|
|
28
|
+
body: '',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
return { rawFrontmatter: '', body: str };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
rawFrontmatter: str.slice(4, closeIndex),
|
|
36
|
+
body: str.slice(closeIndex + 5),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Utilities for reading and resolving JSON Schema nodes.
|
|
3
|
+
* Provides type resolution, path traversal, tab extraction, and
|
|
4
|
+
* convenience accessors for common schema annotations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { toTitleCase } from './format';
|
|
8
|
+
|
|
9
|
+
// A generic JSON Schema node represented as a plain object.
|
|
10
|
+
export type SchemaNode = Record<string, unknown>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Discriminated union describing the resolved type of a schema field. All variants carry an optional `nullable` flag set when the field was expressed as `anyOf: [<type>, { type: 'null' }]`.
|
|
14
|
+
*/
|
|
15
|
+
export type FieldType =
|
|
16
|
+
| { kind: 'string'; nullable?: boolean }
|
|
17
|
+
| { kind: 'number'; nullable?: boolean }
|
|
18
|
+
| { kind: 'boolean'; nullable?: boolean }
|
|
19
|
+
| { kind: 'date'; nullable?: boolean }
|
|
20
|
+
| { kind: 'enum'; options: string[]; nullable?: boolean }
|
|
21
|
+
| { kind: 'array'; nullable?: boolean }
|
|
22
|
+
| { kind: 'object'; nullable?: boolean }
|
|
23
|
+
| { kind: 'unknown'; nullable?: boolean };
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A path segment used to address nested values. Strings address object keys; numbers address array indices.
|
|
27
|
+
*/
|
|
28
|
+
export type PathSegment = string | number;
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
//////////////////////////////
|
|
32
|
+
// resolveFieldType
|
|
33
|
+
//////////////////////////////
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolves a JSON Schema node to a `FieldType` discriminated union.
|
|
38
|
+
* Handles anyOf nullable unwrapping, enum detection, and date-time format.
|
|
39
|
+
* @param {SchemaNode} schema - The JSON Schema node to resolve
|
|
40
|
+
* @return {FieldType} The resolved field type descriptor
|
|
41
|
+
*/
|
|
42
|
+
export function resolveFieldType(schema: SchemaNode): FieldType {
|
|
43
|
+
// Unwrap nullable anyOf: [<innerType>, { type: 'null' }]
|
|
44
|
+
const anyOf = schema['anyOf'];
|
|
45
|
+
if (Array.isArray(anyOf)) {
|
|
46
|
+
const nonNull = (anyOf as SchemaNode[]).find((s) => s['type'] !== 'null');
|
|
47
|
+
if (nonNull) {
|
|
48
|
+
const inner = resolveFieldType(nonNull);
|
|
49
|
+
return { ...inner, nullable: true } as FieldType;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const type = schema['type'] as string | undefined;
|
|
54
|
+
const format = schema['format'] as string | undefined;
|
|
55
|
+
const enumValues = schema['enum'];
|
|
56
|
+
|
|
57
|
+
// date-time format takes precedence over plain string
|
|
58
|
+
if (type === 'string' && format === 'date-time') {
|
|
59
|
+
return { kind: 'date' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// enum values present — treat as enum regardless of string subtype
|
|
63
|
+
if (type === 'string' && Array.isArray(enumValues)) {
|
|
64
|
+
return { kind: 'enum', options: enumValues as string[] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (type === 'string') return { kind: 'string' };
|
|
68
|
+
if (type === 'number' || type === 'integer') return { kind: 'number' };
|
|
69
|
+
if (type === 'boolean') return { kind: 'boolean' };
|
|
70
|
+
if (type === 'array') return { kind: 'array' };
|
|
71
|
+
if (type === 'object') return { kind: 'object' };
|
|
72
|
+
|
|
73
|
+
return { kind: 'unknown' };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/*
|
|
77
|
+
//////////////////////////////
|
|
78
|
+
// extractTabs
|
|
79
|
+
//////////////////////////////
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Scans an object schema's properties for `tab` arrays and returns a sorted, deduplicated list of all tab names found.
|
|
84
|
+
* @param {SchemaNode} schema - A JSON Schema node with an optional `properties` map
|
|
85
|
+
* @return {string[]} Sorted, deduplicated list of tab names
|
|
86
|
+
*/
|
|
87
|
+
export function extractTabs(schema: SchemaNode): string[] {
|
|
88
|
+
const properties = getProperties(schema);
|
|
89
|
+
if (!properties) return [];
|
|
90
|
+
|
|
91
|
+
const tabs = new Set<string>();
|
|
92
|
+
|
|
93
|
+
for (const field of Object.values(properties)) {
|
|
94
|
+
const tab = field['tab'];
|
|
95
|
+
if (Array.isArray(tab)) {
|
|
96
|
+
for (const name of tab as string[]) {
|
|
97
|
+
tabs.add(name);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Array.from(tabs).sort();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/*
|
|
106
|
+
//////////////////////////////
|
|
107
|
+
// createDefaultValue
|
|
108
|
+
//////////////////////////////
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Returns a type-appropriate default value for a given JSON Schema node.
|
|
113
|
+
* Honors `schema.default` when present, returns null for nullable types, and recurses into object properties.
|
|
114
|
+
* @param {SchemaNode} schema - The JSON Schema node to generate a default value for
|
|
115
|
+
* @return {unknown} A default value appropriate for the schema type
|
|
116
|
+
*/
|
|
117
|
+
export function createDefaultValue(schema: SchemaNode): unknown {
|
|
118
|
+
// Honour an explicit schema default first
|
|
119
|
+
if ('default' in schema) {
|
|
120
|
+
return schema['default'];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Nullable anyOf — default to null
|
|
124
|
+
const anyOf = schema['anyOf'];
|
|
125
|
+
if (Array.isArray(anyOf)) {
|
|
126
|
+
const hasNull = (anyOf as SchemaNode[]).some((s) => s['type'] === 'null');
|
|
127
|
+
if (hasNull) return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const type = schema['type'] as string | undefined;
|
|
131
|
+
|
|
132
|
+
if (type === 'string') return '';
|
|
133
|
+
if (type === 'number' || type === 'integer') return 0;
|
|
134
|
+
if (type === 'boolean') return false;
|
|
135
|
+
if (type === 'array') return [];
|
|
136
|
+
|
|
137
|
+
if (type === 'object') {
|
|
138
|
+
const properties = getProperties(schema);
|
|
139
|
+
if (!properties) return {};
|
|
140
|
+
const result: Record<string, unknown> = {};
|
|
141
|
+
for (const [key, fieldSchema] of Object.entries(properties)) {
|
|
142
|
+
result[key] = createDefaultValue(fieldSchema);
|
|
143
|
+
}
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/*
|
|
151
|
+
//////////////////////////////
|
|
152
|
+
// getByPath
|
|
153
|
+
//////////////////////////////
|
|
154
|
+
*/
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Reads a deeply nested value from an object by following path segments.
|
|
158
|
+
* Returns `undefined` if any segment along the path is missing.
|
|
159
|
+
* @param {unknown} obj - The root object to traverse
|
|
160
|
+
* @param {PathSegment[]} path - Ordered path segments (string keys or numeric indices)
|
|
161
|
+
* @return {unknown} The value at the resolved path, or undefined if any segment is missing
|
|
162
|
+
*/
|
|
163
|
+
export function getByPath(obj: unknown, path: PathSegment[]): unknown {
|
|
164
|
+
let current: unknown = obj;
|
|
165
|
+
for (const segment of path) {
|
|
166
|
+
if (current === null || current === undefined) return undefined;
|
|
167
|
+
current = (current as Record<string | number, unknown>)[segment];
|
|
168
|
+
}
|
|
169
|
+
return current;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/*
|
|
173
|
+
//////////////////////////////
|
|
174
|
+
// setByPath
|
|
175
|
+
//////////////////////////////
|
|
176
|
+
*/
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Sets a deeply nested value in an object by following path segments, creating intermediate objects as needed.
|
|
180
|
+
* @param {unknown} obj - The root object to mutate
|
|
181
|
+
* @param {PathSegment[]} path - Ordered path segments (string keys or numeric indices)
|
|
182
|
+
* @param {unknown} value - The value to assign at the resolved path
|
|
183
|
+
* @return {void}
|
|
184
|
+
*/
|
|
185
|
+
export function setByPath(
|
|
186
|
+
obj: unknown,
|
|
187
|
+
path: PathSegment[],
|
|
188
|
+
value: unknown,
|
|
189
|
+
): void {
|
|
190
|
+
if (path.length === 0) return;
|
|
191
|
+
|
|
192
|
+
let current = obj as Record<string | number, unknown>;
|
|
193
|
+
|
|
194
|
+
// Walk down to the parent of the final segment, creating objects as needed
|
|
195
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
196
|
+
const segment = path[i];
|
|
197
|
+
if (current[segment] === null || current[segment] === undefined) {
|
|
198
|
+
current[segment] = {};
|
|
199
|
+
}
|
|
200
|
+
current = current[segment] as Record<string | number, unknown>;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
current[path[path.length - 1]] = value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/*
|
|
207
|
+
//////////////////////////////
|
|
208
|
+
// getFieldsForTab
|
|
209
|
+
//////////////////////////////
|
|
210
|
+
*/
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Returns property names from a schema that belong to the given tab.
|
|
214
|
+
* When `tab` is `null`, all property names are returned (no filtering — every field appears in the catch-all Metadata view).
|
|
215
|
+
* @param {SchemaNode} schema - A JSON Schema node with an optional `properties` map
|
|
216
|
+
* @param {string | null} tab - Tab name to filter by, or `null` to return all fields
|
|
217
|
+
* @return {string[]} Array of property names belonging to the specified tab
|
|
218
|
+
*/
|
|
219
|
+
export function getFieldsForTab(
|
|
220
|
+
schema: SchemaNode,
|
|
221
|
+
tab: string | null,
|
|
222
|
+
): string[] {
|
|
223
|
+
const properties = getProperties(schema);
|
|
224
|
+
if (!properties) return [];
|
|
225
|
+
|
|
226
|
+
// Filter out $schema — it's a JSON Schema meta-property that Astro adds
|
|
227
|
+
// to every generated schema, not a user-editable frontmatter field
|
|
228
|
+
const keys = Object.keys(properties).filter((k) => k !== '$schema');
|
|
229
|
+
|
|
230
|
+
// null means "all fields" — no tab filtering applied
|
|
231
|
+
if (tab === null) {
|
|
232
|
+
return keys;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return keys.filter((key) => {
|
|
236
|
+
const fieldTab = properties[key]['tab'];
|
|
237
|
+
return Array.isArray(fieldTab) && (fieldTab as string[]).includes(tab);
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/*
|
|
242
|
+
//////////////////////////////
|
|
243
|
+
// Schema property accessors
|
|
244
|
+
//////////////////////////////
|
|
245
|
+
*/
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Extracts the `properties` map from a schema node, with a safe cast.
|
|
249
|
+
* @param {SchemaNode} schema - A JSON Schema node
|
|
250
|
+
* @return {Record<string, SchemaNode> | undefined} The properties map, or undefined if absent
|
|
251
|
+
*/
|
|
252
|
+
export function getProperties(
|
|
253
|
+
schema: SchemaNode,
|
|
254
|
+
): Record<string, SchemaNode> | undefined {
|
|
255
|
+
return schema['properties'] as Record<string, SchemaNode> | undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Extracts the `required` array from a schema node, returning an empty array if absent.
|
|
260
|
+
* @param {SchemaNode} schema - A JSON Schema node
|
|
261
|
+
* @return {string[]} Array of required property names
|
|
262
|
+
*/
|
|
263
|
+
export function getRequiredFields(schema: SchemaNode): string[] {
|
|
264
|
+
return Array.isArray(schema['required'])
|
|
265
|
+
? (schema['required'] as string[])
|
|
266
|
+
: [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Returns whether a schema node is marked as read-only.
|
|
271
|
+
* @param {SchemaNode} schema - A JSON Schema node
|
|
272
|
+
* @return {boolean} True if the schema has readOnly set to true
|
|
273
|
+
*/
|
|
274
|
+
export function isReadOnly(schema: SchemaNode): boolean {
|
|
275
|
+
return !!(schema['readOnly'] as boolean | undefined);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Returns whether a schema node was unwrapped from a nullable anyOf and flagged as nullable by SchemaField.
|
|
280
|
+
* @param {SchemaNode} schema - A JSON Schema node (possibly annotated with _nullable)
|
|
281
|
+
* @return {boolean} True if the schema has the _nullable annotation
|
|
282
|
+
*/
|
|
283
|
+
export function isNullable(schema: SchemaNode): boolean {
|
|
284
|
+
return !!(schema['_nullable'] as boolean | undefined);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Returns the display label for a schema field — the schema title if present, otherwise the property name converted to title case.
|
|
289
|
+
* @param {SchemaNode} schema - A JSON Schema node
|
|
290
|
+
* @param {string} name - The raw property name used as a fallback
|
|
291
|
+
* @return {string} The human-readable label
|
|
292
|
+
*/
|
|
293
|
+
export function getLabel(schema: SchemaNode, name: string): string {
|
|
294
|
+
return (schema['title'] as string | undefined) ?? toTitleCase(name);
|
|
295
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* URL-friendly slug generation without external dependencies.
|
|
3
|
+
* Replicates the behavior of the `slugify` package with `lower: true, strict: true`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Converts a string to a URL-friendly slug.
|
|
8
|
+
* Lowercases, replaces non-alphanumeric characters with hyphens, collapses consecutive hyphens, and trims edge hyphens.
|
|
9
|
+
* @param {string} input - The string to slugify
|
|
10
|
+
* @return {string} A URL-safe slug
|
|
11
|
+
*/
|
|
12
|
+
export function slugify(input: string): string {
|
|
13
|
+
return input
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
16
|
+
.replace(/-{2,}/g, '-')
|
|
17
|
+
.replace(/^-|-$/g, '');
|
|
18
|
+
}
|