@getrefino/core 0.1.0-rc.1
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 +21 -0
- package/README.md +15 -0
- package/dist/adapter.d.ts +80 -0
- package/dist/adapter.js +84 -0
- package/dist/api.d.ts +40 -0
- package/dist/api.js +65 -0
- package/dist/content.d.ts +51 -0
- package/dist/content.js +126 -0
- package/dist/draft.d.ts +33 -0
- package/dist/draft.js +68 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.js +28 -0
- package/dist/ids.d.ts +11 -0
- package/dist/ids.js +21 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +6 -0
- package/dist/local-file.d.ts +8 -0
- package/dist/local-file.js +72 -0
- package/package.json +55 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Refino contributors
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# @getrefino/core
|
|
2
|
+
|
|
3
|
+
Content model, draft state and persistence contracts for **Refino** — in-browser
|
|
4
|
+
editing of an existing website's copy, with the repository as the source of
|
|
5
|
+
truth. Framework-independent.
|
|
6
|
+
|
|
7
|
+
Installed for you by `npx @getrefino/cli init`.
|
|
8
|
+
|
|
9
|
+
Browser-safe main entry: content ids, `parseCopy` / `serializeCopy`,
|
|
10
|
+
`applyChanges`, draft helpers, `ContentAdapter`, `planSave`, `createContentApi`.
|
|
11
|
+
|
|
12
|
+
Node-only entry `@getrefino/core/local-file`: `createLocalFileContentAdapter`
|
|
13
|
+
for development.
|
|
14
|
+
|
|
15
|
+
<https://refino.dev>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { ContentChanges, ContentId, ContentSnapshot } from "./content.js";
|
|
2
|
+
/** What the browser sends when the user presses Save. */
|
|
3
|
+
export interface SaveRequest {
|
|
4
|
+
/** Revision the draft was based on. Must match the current revision. */
|
|
5
|
+
readonly baseRevision: string;
|
|
6
|
+
/** Only the IDs the user changed, with their new plain-text values. */
|
|
7
|
+
readonly changes: ContentChanges;
|
|
8
|
+
}
|
|
9
|
+
export interface CommitInfo {
|
|
10
|
+
readonly sha: string;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
readonly url?: string;
|
|
13
|
+
}
|
|
14
|
+
export type SaveResult = {
|
|
15
|
+
readonly status: "saved";
|
|
16
|
+
readonly snapshot: ContentSnapshot;
|
|
17
|
+
readonly commit?: CommitInfo;
|
|
18
|
+
} | {
|
|
19
|
+
readonly status: "unchanged";
|
|
20
|
+
readonly snapshot: ContentSnapshot;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The persistence boundary. The React layer only ever talks to an HTTP
|
|
24
|
+
* endpoint that wraps one of these; it never knows where content lives.
|
|
25
|
+
*/
|
|
26
|
+
export interface ContentAdapter {
|
|
27
|
+
/** Human-readable adapter name for logs and UI, e.g. "github" or "local-file". */
|
|
28
|
+
readonly name: string;
|
|
29
|
+
/** Read the canonical content and its current revision. */
|
|
30
|
+
load(): Promise<ContentSnapshot>;
|
|
31
|
+
/**
|
|
32
|
+
* Persist changes on top of `baseRevision`.
|
|
33
|
+
* Must throw ContentConflictError if the current revision differs.
|
|
34
|
+
* Must not write anything when the resulting file would be byte-identical.
|
|
35
|
+
*/
|
|
36
|
+
save(request: SaveRequest): Promise<SaveResult>;
|
|
37
|
+
}
|
|
38
|
+
export declare const MAX_CHANGES_PER_SAVE = 500;
|
|
39
|
+
/**
|
|
40
|
+
* Validate an untrusted request body (for example `await request.json()`).
|
|
41
|
+
* Anything the browser sends beyond `baseRevision` and `changes` is ignored;
|
|
42
|
+
* there is deliberately no way to name a file path or repository.
|
|
43
|
+
*/
|
|
44
|
+
export declare function parseSaveRequest(body: unknown): SaveRequest;
|
|
45
|
+
/** The current state of the stored file, as an adapter sees it. */
|
|
46
|
+
export interface CurrentFile extends ContentSnapshot {
|
|
47
|
+
/** Exact text of the stored file, used to detect no-op writes. */
|
|
48
|
+
readonly text: string;
|
|
49
|
+
}
|
|
50
|
+
export type SavePlan = {
|
|
51
|
+
readonly kind: "unchanged";
|
|
52
|
+
} | {
|
|
53
|
+
readonly kind: "write";
|
|
54
|
+
readonly text: string;
|
|
55
|
+
readonly content: ContentSnapshot["content"];
|
|
56
|
+
readonly changedIds: readonly ContentId[];
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Shared save logic for every adapter:
|
|
60
|
+
* 1. refuse if the stored revision moved (conflict),
|
|
61
|
+
* 2. validate and apply changes (unknown IDs rejected, unrelated keys kept),
|
|
62
|
+
* 3. serialize deterministically,
|
|
63
|
+
* 4. report "unchanged" when bytes are identical so no commit is made.
|
|
64
|
+
*/
|
|
65
|
+
export declare function planSave(current: CurrentFile, request: SaveRequest): SavePlan;
|
|
66
|
+
export interface CommitMessageOptions {
|
|
67
|
+
/** Subject prefix; defaults to "Edit copy". */
|
|
68
|
+
readonly prefix?: string;
|
|
69
|
+
/** Text appended to the body, e.g. who made the edit. */
|
|
70
|
+
readonly trailer?: string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Build a git commit message that reads well in `git log`:
|
|
74
|
+
*
|
|
75
|
+
* Edit copy: home.hero.title
|
|
76
|
+
*
|
|
77
|
+
* Updated via inline copy editor.
|
|
78
|
+
* - home.hero.title
|
|
79
|
+
*/
|
|
80
|
+
export declare function buildCommitMessage(changedIds: readonly ContentId[], options?: CommitMessageOptions): string;
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { applyChanges, isValidValue, serializeCopy } from "./content.js";
|
|
2
|
+
import { ContentConflictError, ContentError } from "./errors.js";
|
|
3
|
+
import { isContentId } from "./ids.js";
|
|
4
|
+
export const MAX_CHANGES_PER_SAVE = 500;
|
|
5
|
+
function isPlainObject(value) {
|
|
6
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validate an untrusted request body (for example `await request.json()`).
|
|
10
|
+
* Anything the browser sends beyond `baseRevision` and `changes` is ignored;
|
|
11
|
+
* there is deliberately no way to name a file path or repository.
|
|
12
|
+
*/
|
|
13
|
+
export function parseSaveRequest(body) {
|
|
14
|
+
if (!isPlainObject(body)) {
|
|
15
|
+
throw new ContentError("INVALID_REQUEST", "Save request must be a JSON object.");
|
|
16
|
+
}
|
|
17
|
+
const { baseRevision, changes } = body;
|
|
18
|
+
if (typeof baseRevision !== "string" || baseRevision.length === 0 || baseRevision.length > 256) {
|
|
19
|
+
throw new ContentError("INVALID_REQUEST", "Save request is missing baseRevision.");
|
|
20
|
+
}
|
|
21
|
+
if (!isPlainObject(changes)) {
|
|
22
|
+
throw new ContentError("INVALID_REQUEST", "Save request is missing changes.");
|
|
23
|
+
}
|
|
24
|
+
const entries = Object.entries(changes);
|
|
25
|
+
if (entries.length > MAX_CHANGES_PER_SAVE) {
|
|
26
|
+
throw new ContentError("INVALID_REQUEST", `Too many changes in one save (max ${MAX_CHANGES_PER_SAVE}).`);
|
|
27
|
+
}
|
|
28
|
+
const validated = {};
|
|
29
|
+
for (const [id, value] of entries) {
|
|
30
|
+
if (!isContentId(id)) {
|
|
31
|
+
throw new ContentError("INVALID_ID", `Invalid content ID "${id}".`, { id });
|
|
32
|
+
}
|
|
33
|
+
if (!isValidValue(value)) {
|
|
34
|
+
throw new ContentError("INVALID_VALUE", `Value for "${id}" must be a plain string.`, { id });
|
|
35
|
+
}
|
|
36
|
+
validated[id] = value;
|
|
37
|
+
}
|
|
38
|
+
return { baseRevision, changes: validated };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Shared save logic for every adapter:
|
|
42
|
+
* 1. refuse if the stored revision moved (conflict),
|
|
43
|
+
* 2. validate and apply changes (unknown IDs rejected, unrelated keys kept),
|
|
44
|
+
* 3. serialize deterministically,
|
|
45
|
+
* 4. report "unchanged" when bytes are identical so no commit is made.
|
|
46
|
+
*/
|
|
47
|
+
export function planSave(current, request) {
|
|
48
|
+
if (current.revision !== request.baseRevision) {
|
|
49
|
+
throw new ContentConflictError("The copy file changed since you started editing. Reload the latest copy and reapply your edits.", { content: current.content, revision: current.revision });
|
|
50
|
+
}
|
|
51
|
+
const applied = applyChanges(current.content, request.changes);
|
|
52
|
+
const text = serializeCopy(applied.content);
|
|
53
|
+
if (text === current.text) {
|
|
54
|
+
return { kind: "unchanged" };
|
|
55
|
+
}
|
|
56
|
+
return { kind: "write", text, content: applied.content, changedIds: applied.changedIds };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Build a git commit message that reads well in `git log`:
|
|
60
|
+
*
|
|
61
|
+
* Edit copy: home.hero.title
|
|
62
|
+
*
|
|
63
|
+
* Updated via inline copy editor.
|
|
64
|
+
* - home.hero.title
|
|
65
|
+
*/
|
|
66
|
+
export function buildCommitMessage(changedIds, options = {}) {
|
|
67
|
+
const prefix = options.prefix ?? "Edit copy";
|
|
68
|
+
const ids = [...changedIds];
|
|
69
|
+
const joined = ids.join(", ");
|
|
70
|
+
let subject;
|
|
71
|
+
if (ids.length === 0) {
|
|
72
|
+
subject = prefix;
|
|
73
|
+
}
|
|
74
|
+
else if (ids.length <= 3 && `${prefix}: ${joined}`.length <= 72) {
|
|
75
|
+
subject = `${prefix}: ${joined}`;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
subject = `${prefix} (${ids.length} fields)`;
|
|
79
|
+
}
|
|
80
|
+
const bodyLines = ["Updated via inline copy editor.", ...ids.map((id) => `- ${id}`)];
|
|
81
|
+
if (options.trailer)
|
|
82
|
+
bodyLines.push("", options.trailer);
|
|
83
|
+
return `${subject}\n\n${bodyLines.join("\n")}\n`;
|
|
84
|
+
}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ContentAdapter, SaveResult } from "./adapter.js";
|
|
2
|
+
import type { ContentSnapshot } from "./content.js";
|
|
3
|
+
import type { ContentErrorCode } from "./errors.js";
|
|
4
|
+
/**
|
|
5
|
+
* Wire format between the React editor and the host's authenticated
|
|
6
|
+
* endpoint. Hosts wrap `createContentApi` in their own route handler and
|
|
7
|
+
* are responsible for authentication; this layer never sees credentials.
|
|
8
|
+
*/
|
|
9
|
+
export type ContentApiBody = {
|
|
10
|
+
readonly ok: true;
|
|
11
|
+
readonly snapshot: ContentSnapshot;
|
|
12
|
+
} | {
|
|
13
|
+
readonly ok: true;
|
|
14
|
+
readonly result: SaveResult;
|
|
15
|
+
} | {
|
|
16
|
+
readonly ok: false;
|
|
17
|
+
readonly error: {
|
|
18
|
+
readonly code: ContentErrorCode | "INTERNAL";
|
|
19
|
+
readonly message: string;
|
|
20
|
+
/** Present on CONFLICT so the editor can rebase without another request. */
|
|
21
|
+
readonly current?: ContentSnapshot;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
export interface ContentApiResponse {
|
|
25
|
+
readonly status: number;
|
|
26
|
+
readonly body: ContentApiBody;
|
|
27
|
+
}
|
|
28
|
+
export interface ContentApi {
|
|
29
|
+
load(): Promise<ContentApiResponse>;
|
|
30
|
+
save(body: unknown): Promise<ContentApiResponse>;
|
|
31
|
+
}
|
|
32
|
+
export interface ContentApiOptions {
|
|
33
|
+
/** Receives unexpected (non-ContentError) failures for logging. */
|
|
34
|
+
readonly onError?: (error: unknown) => void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Framework-agnostic request handling. Returns plain status + JSON body so
|
|
38
|
+
* it can sit behind Next.js, Express, Hono, Remix, or a test harness.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createContentApi(adapter: ContentAdapter, options?: ContentApiOptions): ContentApi;
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { parseSaveRequest } from "./adapter.js";
|
|
2
|
+
import { isContentConflictError, isContentError } from "./errors.js";
|
|
3
|
+
const STATUS_BY_CODE = {
|
|
4
|
+
INVALID_ID: 400,
|
|
5
|
+
UNKNOWN_ID: 400,
|
|
6
|
+
INVALID_VALUE: 400,
|
|
7
|
+
INVALID_CONTENT: 500,
|
|
8
|
+
INVALID_REQUEST: 400,
|
|
9
|
+
CONFLICT: 409,
|
|
10
|
+
NOT_FOUND: 404,
|
|
11
|
+
NOT_CONFIGURED: 503,
|
|
12
|
+
ADAPTER_ERROR: 502,
|
|
13
|
+
};
|
|
14
|
+
function toErrorResponse(error, options) {
|
|
15
|
+
if (isContentConflictError(error)) {
|
|
16
|
+
return {
|
|
17
|
+
status: 409,
|
|
18
|
+
body: {
|
|
19
|
+
ok: false,
|
|
20
|
+
error: { code: "CONFLICT", message: error.message, current: error.current },
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
if (isContentError(error)) {
|
|
25
|
+
return {
|
|
26
|
+
status: STATUS_BY_CODE[error.code],
|
|
27
|
+
body: { ok: false, error: { code: error.code, message: error.message } },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
options.onError?.(error);
|
|
31
|
+
return {
|
|
32
|
+
status: 500,
|
|
33
|
+
body: {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: { code: "INTERNAL", message: "Unexpected error while handling copy request." },
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Framework-agnostic request handling. Returns plain status + JSON body so
|
|
41
|
+
* it can sit behind Next.js, Express, Hono, Remix, or a test harness.
|
|
42
|
+
*/
|
|
43
|
+
export function createContentApi(adapter, options = {}) {
|
|
44
|
+
return {
|
|
45
|
+
async load() {
|
|
46
|
+
try {
|
|
47
|
+
const snapshot = await adapter.load();
|
|
48
|
+
return { status: 200, body: { ok: true, snapshot } };
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
return toErrorResponse(error, options);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
async save(body) {
|
|
55
|
+
try {
|
|
56
|
+
const request = parseSaveRequest(body);
|
|
57
|
+
const result = await adapter.save(request);
|
|
58
|
+
return { status: 200, body: { ok: true, result } };
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return toErrorResponse(error, options);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** A stable semantic identifier such as `home.hero.title`. */
|
|
2
|
+
export type ContentId = string;
|
|
3
|
+
/**
|
|
4
|
+
* The canonical copy: a flat map from content ID to plain-text value.
|
|
5
|
+
* This is exactly the shape of `content/copy.json` in a host repository.
|
|
6
|
+
*/
|
|
7
|
+
export type CopyContent = Readonly<Record<ContentId, string>>;
|
|
8
|
+
/** A partial map of IDs to new values. */
|
|
9
|
+
export type ContentChanges = Readonly<Record<ContentId, string>>;
|
|
10
|
+
/**
|
|
11
|
+
* The content together with an opaque revision string that identifies the
|
|
12
|
+
* exact bytes it was loaded from (a git blob SHA, a content hash, ...).
|
|
13
|
+
*/
|
|
14
|
+
export interface ContentSnapshot {
|
|
15
|
+
readonly content: CopyContent;
|
|
16
|
+
readonly revision: string;
|
|
17
|
+
}
|
|
18
|
+
export declare const MAX_VALUE_LENGTH = 20000;
|
|
19
|
+
export declare function isValidValue(value: unknown): value is string;
|
|
20
|
+
export declare function assertValidValue(id: ContentId, value: unknown): asserts value is string;
|
|
21
|
+
/** Normalize line endings; everything else is kept byte-for-byte. */
|
|
22
|
+
export declare function normalizeValue(value: string): string;
|
|
23
|
+
/**
|
|
24
|
+
* Parse the text of a copy file. Accepts only a flat JSON object whose keys
|
|
25
|
+
* are valid content IDs and whose values are strings. Key order is preserved.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseCopy(text: string): CopyContent;
|
|
28
|
+
/** Validate an already-parsed value as copy content. */
|
|
29
|
+
export declare function validateCopy(value: unknown): CopyContent;
|
|
30
|
+
/**
|
|
31
|
+
* Serialize copy content deterministically: two-space indent, one key per
|
|
32
|
+
* line, keys in their existing order, trailing newline. Same input always
|
|
33
|
+
* produces the same bytes, so a one-word edit is a one-line diff.
|
|
34
|
+
*/
|
|
35
|
+
export declare function serializeCopy(content: CopyContent): string;
|
|
36
|
+
export declare function getCopy(content: CopyContent, id: ContentId): string | undefined;
|
|
37
|
+
export declare function hasCopy(content: CopyContent, id: ContentId): boolean;
|
|
38
|
+
export interface ApplyChangesResult {
|
|
39
|
+
readonly content: CopyContent;
|
|
40
|
+
/** IDs whose value actually differs from the input content. */
|
|
41
|
+
readonly changedIds: readonly ContentId[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Apply a set of changes to content. Every ID must already exist (the editor
|
|
45
|
+
* changes words; it never adds or removes keys). Key order and unrelated
|
|
46
|
+
* values are preserved exactly.
|
|
47
|
+
*/
|
|
48
|
+
export declare function applyChanges(content: CopyContent, changes: ContentChanges): ApplyChangesResult;
|
|
49
|
+
/** IDs present in both objects whose values differ, as a changes map. */
|
|
50
|
+
export declare function diffContent(base: CopyContent, next: CopyContent): ContentChanges;
|
|
51
|
+
export declare function contentEquals(a: CopyContent, b: CopyContent): boolean;
|
package/dist/content.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { ContentError } from "./errors.js";
|
|
2
|
+
import { assertContentId, isContentId } from "./ids.js";
|
|
3
|
+
export const MAX_VALUE_LENGTH = 20_000;
|
|
4
|
+
/** Disallow ASCII control characters except newline, carriage return and tab. */
|
|
5
|
+
function hasControlCharacters(value) {
|
|
6
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
7
|
+
const code = value.charCodeAt(index);
|
|
8
|
+
if (code === 0x0a || code === 0x0d || code === 0x09)
|
|
9
|
+
continue;
|
|
10
|
+
if (code < 0x20 || code === 0x7f)
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
export function isValidValue(value) {
|
|
16
|
+
return (typeof value === "string" &&
|
|
17
|
+
value.length <= MAX_VALUE_LENGTH &&
|
|
18
|
+
!hasControlCharacters(value));
|
|
19
|
+
}
|
|
20
|
+
export function assertValidValue(id, value) {
|
|
21
|
+
if (typeof value !== "string") {
|
|
22
|
+
throw new ContentError("INVALID_VALUE", `Value for "${id}" must be a string.`, { id });
|
|
23
|
+
}
|
|
24
|
+
if (value.length > MAX_VALUE_LENGTH) {
|
|
25
|
+
throw new ContentError("INVALID_VALUE", `Value for "${id}" is longer than ${MAX_VALUE_LENGTH} characters.`, { id });
|
|
26
|
+
}
|
|
27
|
+
if (hasControlCharacters(value)) {
|
|
28
|
+
throw new ContentError("INVALID_VALUE", `Value for "${id}" contains control characters.`, { id });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Normalize line endings; everything else is kept byte-for-byte. */
|
|
32
|
+
export function normalizeValue(value) {
|
|
33
|
+
return value.replace(/\r\n?/g, "\n");
|
|
34
|
+
}
|
|
35
|
+
function isPlainObject(value) {
|
|
36
|
+
return (typeof value === "object" &&
|
|
37
|
+
value !== null &&
|
|
38
|
+
!Array.isArray(value) &&
|
|
39
|
+
Object.getPrototypeOf(value) === Object.prototype);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse the text of a copy file. Accepts only a flat JSON object whose keys
|
|
43
|
+
* are valid content IDs and whose values are strings. Key order is preserved.
|
|
44
|
+
*/
|
|
45
|
+
export function parseCopy(text) {
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
throw new ContentError("INVALID_CONTENT", "Copy file is not valid JSON.", {
|
|
52
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return validateCopy(parsed);
|
|
56
|
+
}
|
|
57
|
+
/** Validate an already-parsed value as copy content. */
|
|
58
|
+
export function validateCopy(value) {
|
|
59
|
+
if (!isPlainObject(value)) {
|
|
60
|
+
throw new ContentError("INVALID_CONTENT", "Copy content must be a JSON object mapping IDs to strings.");
|
|
61
|
+
}
|
|
62
|
+
const result = {};
|
|
63
|
+
for (const [id, entry] of Object.entries(value)) {
|
|
64
|
+
if (!isContentId(id)) {
|
|
65
|
+
throw new ContentError("INVALID_CONTENT", `Copy file contains an invalid ID "${id}".`, { id });
|
|
66
|
+
}
|
|
67
|
+
if (!isValidValue(entry)) {
|
|
68
|
+
throw new ContentError("INVALID_CONTENT", `Copy file value for "${id}" must be a plain string without control characters.`, { id });
|
|
69
|
+
}
|
|
70
|
+
result[id] = entry;
|
|
71
|
+
}
|
|
72
|
+
return result;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Serialize copy content deterministically: two-space indent, one key per
|
|
76
|
+
* line, keys in their existing order, trailing newline. Same input always
|
|
77
|
+
* produces the same bytes, so a one-word edit is a one-line diff.
|
|
78
|
+
*/
|
|
79
|
+
export function serializeCopy(content) {
|
|
80
|
+
return `${JSON.stringify(content, null, 2)}\n`;
|
|
81
|
+
}
|
|
82
|
+
export function getCopy(content, id) {
|
|
83
|
+
return Object.prototype.hasOwnProperty.call(content, id) ? content[id] : undefined;
|
|
84
|
+
}
|
|
85
|
+
export function hasCopy(content, id) {
|
|
86
|
+
return Object.prototype.hasOwnProperty.call(content, id);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Apply a set of changes to content. Every ID must already exist (the editor
|
|
90
|
+
* changes words; it never adds or removes keys). Key order and unrelated
|
|
91
|
+
* values are preserved exactly.
|
|
92
|
+
*/
|
|
93
|
+
export function applyChanges(content, changes) {
|
|
94
|
+
const next = { ...content };
|
|
95
|
+
const changedIds = [];
|
|
96
|
+
for (const [id, rawValue] of Object.entries(changes)) {
|
|
97
|
+
assertContentId(id);
|
|
98
|
+
if (!hasCopy(content, id)) {
|
|
99
|
+
throw new ContentError("UNKNOWN_ID", `Unknown content ID "${id}".`, { id });
|
|
100
|
+
}
|
|
101
|
+
assertValidValue(id, rawValue);
|
|
102
|
+
const value = normalizeValue(rawValue);
|
|
103
|
+
if (value !== content[id]) {
|
|
104
|
+
next[id] = value;
|
|
105
|
+
changedIds.push(id);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { content: next, changedIds };
|
|
109
|
+
}
|
|
110
|
+
/** IDs present in both objects whose values differ, as a changes map. */
|
|
111
|
+
export function diffContent(base, next) {
|
|
112
|
+
const changes = {};
|
|
113
|
+
for (const [id, value] of Object.entries(next)) {
|
|
114
|
+
if (hasCopy(base, id) && base[id] !== value) {
|
|
115
|
+
changes[id] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return changes;
|
|
119
|
+
}
|
|
120
|
+
export function contentEquals(a, b) {
|
|
121
|
+
const aKeys = Object.keys(a);
|
|
122
|
+
const bKeys = Object.keys(b);
|
|
123
|
+
if (aKeys.length !== bKeys.length)
|
|
124
|
+
return false;
|
|
125
|
+
return aKeys.every((id) => hasCopy(b, id) && a[id] === b[id]);
|
|
126
|
+
}
|
package/dist/draft.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ContentChanges, ContentId, CopyContent } from "./content.js";
|
|
2
|
+
/**
|
|
3
|
+
* Client-side draft state: the base content the session started from plus
|
|
4
|
+
* the set of IDs the user has changed. Nothing is persisted until save.
|
|
5
|
+
* All functions are pure and return new objects.
|
|
6
|
+
*/
|
|
7
|
+
export interface DraftState {
|
|
8
|
+
readonly base: CopyContent;
|
|
9
|
+
readonly changes: ContentChanges;
|
|
10
|
+
}
|
|
11
|
+
export declare function createDraft(base: CopyContent): DraftState;
|
|
12
|
+
export declare function setDraftValue(draft: DraftState, id: ContentId, rawValue: string): DraftState;
|
|
13
|
+
export declare function revertDraftValue(draft: DraftState, id: ContentId): DraftState;
|
|
14
|
+
export declare function revertAllDraft(draft: DraftState): DraftState;
|
|
15
|
+
export declare function getDraftValue(draft: DraftState, id: ContentId): string | undefined;
|
|
16
|
+
export declare function isDraftValueDirty(draft: DraftState, id: ContentId): boolean;
|
|
17
|
+
export declare function getDirtyIds(draft: DraftState): ContentId[];
|
|
18
|
+
export declare function isDraftDirty(draft: DraftState): boolean;
|
|
19
|
+
/** The base content with the draft changes applied. */
|
|
20
|
+
export declare function getDraftContent(draft: DraftState): CopyContent;
|
|
21
|
+
export interface RebaseResult {
|
|
22
|
+
readonly draft: DraftState;
|
|
23
|
+
/** Dirty IDs whose base value also changed upstream. Worth a human look. */
|
|
24
|
+
readonly overlapping: readonly ContentId[];
|
|
25
|
+
/** Dirty IDs that no longer exist upstream; their edits were dropped. */
|
|
26
|
+
readonly dropped: readonly ContentId[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Move a draft onto newer base content, keeping the user's unsaved edits.
|
|
30
|
+
* This is the v1 conflict workflow: reload the latest copy, reapply edits,
|
|
31
|
+
* tell the user which fields changed on both sides, let them decide.
|
|
32
|
+
*/
|
|
33
|
+
export declare function rebaseDraft(draft: DraftState, newBase: CopyContent): RebaseResult;
|
package/dist/draft.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ContentError } from "./errors.js";
|
|
2
|
+
import { hasCopy, normalizeValue } from "./content.js";
|
|
3
|
+
export function createDraft(base) {
|
|
4
|
+
return { base, changes: {} };
|
|
5
|
+
}
|
|
6
|
+
export function setDraftValue(draft, id, rawValue) {
|
|
7
|
+
if (!hasCopy(draft.base, id)) {
|
|
8
|
+
throw new ContentError("UNKNOWN_ID", `Unknown content ID "${id}".`, { id });
|
|
9
|
+
}
|
|
10
|
+
const value = normalizeValue(rawValue);
|
|
11
|
+
if (value === draft.base[id]) {
|
|
12
|
+
return revertDraftValue(draft, id);
|
|
13
|
+
}
|
|
14
|
+
if (draft.changes[id] === value)
|
|
15
|
+
return draft;
|
|
16
|
+
return { base: draft.base, changes: { ...draft.changes, [id]: value } };
|
|
17
|
+
}
|
|
18
|
+
export function revertDraftValue(draft, id) {
|
|
19
|
+
if (!hasCopy(draft.changes, id))
|
|
20
|
+
return draft;
|
|
21
|
+
const changes = { ...draft.changes };
|
|
22
|
+
delete changes[id];
|
|
23
|
+
return { base: draft.base, changes };
|
|
24
|
+
}
|
|
25
|
+
export function revertAllDraft(draft) {
|
|
26
|
+
return createDraft(draft.base);
|
|
27
|
+
}
|
|
28
|
+
export function getDraftValue(draft, id) {
|
|
29
|
+
if (hasCopy(draft.changes, id))
|
|
30
|
+
return draft.changes[id];
|
|
31
|
+
return hasCopy(draft.base, id) ? draft.base[id] : undefined;
|
|
32
|
+
}
|
|
33
|
+
export function isDraftValueDirty(draft, id) {
|
|
34
|
+
return hasCopy(draft.changes, id);
|
|
35
|
+
}
|
|
36
|
+
export function getDirtyIds(draft) {
|
|
37
|
+
return Object.keys(draft.changes);
|
|
38
|
+
}
|
|
39
|
+
export function isDraftDirty(draft) {
|
|
40
|
+
return Object.keys(draft.changes).length > 0;
|
|
41
|
+
}
|
|
42
|
+
/** The base content with the draft changes applied. */
|
|
43
|
+
export function getDraftContent(draft) {
|
|
44
|
+
return { ...draft.base, ...draft.changes };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Move a draft onto newer base content, keeping the user's unsaved edits.
|
|
48
|
+
* This is the v1 conflict workflow: reload the latest copy, reapply edits,
|
|
49
|
+
* tell the user which fields changed on both sides, let them decide.
|
|
50
|
+
*/
|
|
51
|
+
export function rebaseDraft(draft, newBase) {
|
|
52
|
+
const changes = {};
|
|
53
|
+
const overlapping = [];
|
|
54
|
+
const dropped = [];
|
|
55
|
+
for (const [id, value] of Object.entries(draft.changes)) {
|
|
56
|
+
if (!hasCopy(newBase, id)) {
|
|
57
|
+
dropped.push(id);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (newBase[id] !== draft.base[id]) {
|
|
61
|
+
overlapping.push(id);
|
|
62
|
+
}
|
|
63
|
+
if (newBase[id] !== value) {
|
|
64
|
+
changes[id] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { draft: { base: newBase, changes }, overlapping, dropped };
|
|
68
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ContentSnapshot } from "./content.js";
|
|
2
|
+
/**
|
|
3
|
+
* Every failure the editor can surface has one of these codes.
|
|
4
|
+
* They are stable strings so hosts and UIs can branch on them.
|
|
5
|
+
*/
|
|
6
|
+
export type ContentErrorCode = "INVALID_ID" | "UNKNOWN_ID" | "INVALID_VALUE" | "INVALID_CONTENT" | "INVALID_REQUEST" | "CONFLICT" | "NOT_FOUND" | "NOT_CONFIGURED" | "ADAPTER_ERROR";
|
|
7
|
+
export declare class ContentError extends Error {
|
|
8
|
+
readonly code: ContentErrorCode;
|
|
9
|
+
readonly details: Readonly<Record<string, unknown>>;
|
|
10
|
+
constructor(code: ContentErrorCode, message: string, details?: Record<string, unknown>);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Raised when the content file changed underneath an editing session.
|
|
14
|
+
* `current` is the newest snapshot so the editor can rebase the draft on it.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ContentConflictError extends ContentError {
|
|
17
|
+
readonly current: ContentSnapshot;
|
|
18
|
+
constructor(message: string, current: ContentSnapshot);
|
|
19
|
+
}
|
|
20
|
+
export declare function isContentError(value: unknown): value is ContentError;
|
|
21
|
+
export declare function isContentConflictError(value: unknown): value is ContentConflictError;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export class ContentError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
details;
|
|
4
|
+
constructor(code, message, details = {}) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ContentError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.details = details;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Raised when the content file changed underneath an editing session.
|
|
13
|
+
* `current` is the newest snapshot so the editor can rebase the draft on it.
|
|
14
|
+
*/
|
|
15
|
+
export class ContentConflictError extends ContentError {
|
|
16
|
+
current;
|
|
17
|
+
constructor(message, current) {
|
|
18
|
+
super("CONFLICT", message, { currentRevision: current.revision });
|
|
19
|
+
this.name = "ContentConflictError";
|
|
20
|
+
this.current = current;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function isContentError(value) {
|
|
24
|
+
return value instanceof ContentError;
|
|
25
|
+
}
|
|
26
|
+
export function isContentConflictError(value) {
|
|
27
|
+
return value instanceof ContentConflictError;
|
|
28
|
+
}
|
package/dist/ids.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A content ID is a dotted path of short segments, for example
|
|
3
|
+
* `home.hero.title` or `faq.items.1.question`.
|
|
4
|
+
*
|
|
5
|
+
* Segments may contain letters, digits, `-` and `_`.
|
|
6
|
+
* IDs are stable identifiers: rename them deliberately, never automatically.
|
|
7
|
+
*/
|
|
8
|
+
export declare const CONTENT_ID_PATTERN: RegExp;
|
|
9
|
+
export declare const MAX_CONTENT_ID_LENGTH = 200;
|
|
10
|
+
export declare function isContentId(value: unknown): value is string;
|
|
11
|
+
export declare function assertContentId(value: unknown): asserts value is string;
|
package/dist/ids.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { ContentError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* A content ID is a dotted path of short segments, for example
|
|
4
|
+
* `home.hero.title` or `faq.items.1.question`.
|
|
5
|
+
*
|
|
6
|
+
* Segments may contain letters, digits, `-` and `_`.
|
|
7
|
+
* IDs are stable identifiers: rename them deliberately, never automatically.
|
|
8
|
+
*/
|
|
9
|
+
export const CONTENT_ID_PATTERN = /^[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*$/;
|
|
10
|
+
export const MAX_CONTENT_ID_LENGTH = 200;
|
|
11
|
+
export function isContentId(value) {
|
|
12
|
+
return (typeof value === "string" &&
|
|
13
|
+
value.length > 0 &&
|
|
14
|
+
value.length <= MAX_CONTENT_ID_LENGTH &&
|
|
15
|
+
CONTENT_ID_PATTERN.test(value));
|
|
16
|
+
}
|
|
17
|
+
export function assertContentId(value) {
|
|
18
|
+
if (!isContentId(value)) {
|
|
19
|
+
throw new ContentError("INVALID_ID", `Invalid content ID ${JSON.stringify(value)}. Use dotted segments like "home.hero.title".`, { id: value });
|
|
20
|
+
}
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { CONTENT_ID_PATTERN, MAX_CONTENT_ID_LENGTH, assertContentId, isContentId, } from "./ids.js";
|
|
2
|
+
export { MAX_VALUE_LENGTH, applyChanges, assertValidValue, contentEquals, diffContent, getCopy, hasCopy, isValidValue, normalizeValue, parseCopy, serializeCopy, validateCopy, } from "./content.js";
|
|
3
|
+
export type { ApplyChangesResult, ContentChanges, ContentId, ContentSnapshot, CopyContent, } from "./content.js";
|
|
4
|
+
export { createDraft, getDirtyIds, getDraftContent, getDraftValue, isDraftDirty, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, } from "./draft.js";
|
|
5
|
+
export type { DraftState, RebaseResult } from "./draft.js";
|
|
6
|
+
export { MAX_CHANGES_PER_SAVE, buildCommitMessage, parseSaveRequest, planSave, } from "./adapter.js";
|
|
7
|
+
export type { CommitInfo, CommitMessageOptions, ContentAdapter, CurrentFile, SavePlan, SaveRequest, SaveResult, } from "./adapter.js";
|
|
8
|
+
export { createContentApi } from "./api.js";
|
|
9
|
+
export type { ContentApi, ContentApiBody, ContentApiOptions, ContentApiResponse, } from "./api.js";
|
|
10
|
+
export { ContentConflictError, ContentError, isContentConflictError, isContentError, } from "./errors.js";
|
|
11
|
+
export type { ContentErrorCode } from "./errors.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { CONTENT_ID_PATTERN, MAX_CONTENT_ID_LENGTH, assertContentId, isContentId, } from "./ids.js";
|
|
2
|
+
export { MAX_VALUE_LENGTH, applyChanges, assertValidValue, contentEquals, diffContent, getCopy, hasCopy, isValidValue, normalizeValue, parseCopy, serializeCopy, validateCopy, } from "./content.js";
|
|
3
|
+
export { createDraft, getDirtyIds, getDraftContent, getDraftValue, isDraftDirty, isDraftValueDirty, rebaseDraft, revertAllDraft, revertDraftValue, setDraftValue, } from "./draft.js";
|
|
4
|
+
export { MAX_CHANGES_PER_SAVE, buildCommitMessage, parseSaveRequest, planSave, } from "./adapter.js";
|
|
5
|
+
export { createContentApi } from "./api.js";
|
|
6
|
+
export { ContentConflictError, ContentError, isContentConflictError, isContentError, } from "./errors.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ContentAdapter } from "./adapter.js";
|
|
2
|
+
export interface LocalFileContentAdapterOptions {
|
|
3
|
+
/** Absolute path to the copy file, e.g. `path.join(process.cwd(), "content/copy.json")`. */
|
|
4
|
+
readonly filePath: string;
|
|
5
|
+
}
|
|
6
|
+
/** Revision for local files: a hash of the exact bytes on disk. */
|
|
7
|
+
export declare function computeTextRevision(text: string): string;
|
|
8
|
+
export declare function createLocalFileContentAdapter(options: LocalFileContentAdapterOptions): ContentAdapter;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local filesystem adapter (Node.js only). Imported as
|
|
3
|
+
* `@getrefino/core/local-file` so the browser-safe main entry never
|
|
4
|
+
* touches `node:fs`.
|
|
5
|
+
*
|
|
6
|
+
* Reads and writes the canonical copy file in place, so the development
|
|
7
|
+
* loop is: edit in the browser, save, see the JSON diff in git.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
import { readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { planSave } from "./adapter.js";
|
|
13
|
+
import { parseCopy } from "./content.js";
|
|
14
|
+
import { ContentError } from "./errors.js";
|
|
15
|
+
/** Revision for local files: a hash of the exact bytes on disk. */
|
|
16
|
+
export function computeTextRevision(text) {
|
|
17
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
18
|
+
}
|
|
19
|
+
export function createLocalFileContentAdapter(options) {
|
|
20
|
+
const { filePath } = options;
|
|
21
|
+
if (!filePath) {
|
|
22
|
+
throw new ContentError("NOT_CONFIGURED", "Local file adapter needs a filePath.");
|
|
23
|
+
}
|
|
24
|
+
async function readCurrent() {
|
|
25
|
+
let text;
|
|
26
|
+
try {
|
|
27
|
+
text = await readFile(filePath, "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
const code = error.code;
|
|
31
|
+
if (code === "ENOENT") {
|
|
32
|
+
throw new ContentError("NOT_FOUND", `Copy file not found at ${filePath}.`, { filePath });
|
|
33
|
+
}
|
|
34
|
+
throw new ContentError("ADAPTER_ERROR", `Could not read copy file: ${String(error)}`);
|
|
35
|
+
}
|
|
36
|
+
return { text, content: parseCopy(text), revision: computeTextRevision(text) };
|
|
37
|
+
}
|
|
38
|
+
/** Write to a sibling temp file and rename so readers never see a partial file. */
|
|
39
|
+
async function writeAtomically(text) {
|
|
40
|
+
const tempPath = join(dirname(filePath), `.${process.pid}-${Date.now()}.copy.tmp`);
|
|
41
|
+
try {
|
|
42
|
+
await writeFile(tempPath, text, "utf8");
|
|
43
|
+
await rename(tempPath, filePath);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
await rm(tempPath, { force: true });
|
|
47
|
+
throw new ContentError("ADAPTER_ERROR", `Could not write copy file: ${String(error)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
name: "local-file",
|
|
52
|
+
async load() {
|
|
53
|
+
const { content, revision } = await readCurrent();
|
|
54
|
+
return { content, revision };
|
|
55
|
+
},
|
|
56
|
+
async save(request) {
|
|
57
|
+
const current = await readCurrent();
|
|
58
|
+
const plan = planSave(current, request);
|
|
59
|
+
if (plan.kind === "unchanged") {
|
|
60
|
+
return {
|
|
61
|
+
status: "unchanged",
|
|
62
|
+
snapshot: { content: current.content, revision: current.revision },
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
await writeAtomically(plan.text);
|
|
66
|
+
return {
|
|
67
|
+
status: "saved",
|
|
68
|
+
snapshot: { content: plan.content, revision: computeTextRevision(plan.text) },
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@getrefino/core",
|
|
3
|
+
"version": "0.1.0-rc.1",
|
|
4
|
+
"description": "Core content model, draft state and persistence contracts for Refino, the in-browser editor for existing website copy. Framework-independent; the repository stays the source of truth.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"refino",
|
|
7
|
+
"copy",
|
|
8
|
+
"content",
|
|
9
|
+
"editing",
|
|
10
|
+
"cms-alternative",
|
|
11
|
+
"git"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"homepage": "https://refino.dev",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/forant/inline-copy.git",
|
|
18
|
+
"directory": "packages/core"
|
|
19
|
+
},
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": false
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"sideEffects": false,
|
|
26
|
+
"files": [
|
|
27
|
+
"dist/**/*.js",
|
|
28
|
+
"dist/**/*.d.ts",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"exports": {
|
|
33
|
+
".": {
|
|
34
|
+
"types": "./dist/index.d.ts",
|
|
35
|
+
"default": "./dist/index.js"
|
|
36
|
+
},
|
|
37
|
+
"./local-file": {
|
|
38
|
+
"types": "./dist/local-file.d.ts",
|
|
39
|
+
"default": "./dist/local-file.js"
|
|
40
|
+
},
|
|
41
|
+
"./package.json": "./package.json"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "22.20.2",
|
|
45
|
+
"typescript": "6.0.3",
|
|
46
|
+
"vitest": "5.0.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
50
|
+
"dev": "tsc -p tsconfig.build.json --watch --preserveWatchOutput",
|
|
51
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
52
|
+
"test": "vitest run",
|
|
53
|
+
"clean": "rm -rf dist"
|
|
54
|
+
}
|
|
55
|
+
}
|