Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,7 @@
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-unnecessary-boolean-literal-compare": "warn",

"local/no-module-scoped-registry": "error",
"local/no-section-divider-comments": "error",
"local/no-yield-in-finally": "error",
"local/prefer-effection-result": "error"
Expand Down
29 changes: 21 additions & 8 deletions packages/cli/tests/props-schema.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,6 +8,7 @@
*/
import { describe, it } from "@executablemd/test-support/bdd";
import { expect } from "@executablemd/test-support/expect";
import type { Operation } from "effection";
import { validateProps } from "@executablemd/core";
import type { PropsSchema } from "@executablemd/core";
import { z } from "zod";
Expand DownExpand Up@@ -57,6 +58,16 @@ const SCALARS: PropsSchema = {
additionalProperties: false,
};

/** The failure an operation raised, so an assertion can read it. */
function* raised(operation: () => Operation<unknown>): Operation<unknown> {
try {
yield* operation();
} catch (error) {
return error;
}
throw new Error("expected the operation to fail");
}

describe("Tier PS β€” JSON Schema to Standard Schema", () => {
it("PS1: converts every scalar form the contract names", function* () {
const shape = shapeOf(SCALARS);
Expand DownExpand Up@@ -108,8 +119,10 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
required: ["a"],
additionalProperties: false,
};
expect(() => validateProps("x", {}, schema)).toThrow(/required property 'a'/);
expect(validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
expect(String(yield* raised(() => validateProps("x", {}, schema)))).toMatch(
/required property 'a'/,
);
expect(yield* validateProps("x", { a: "1" }, schema)).toEqual({ a: "1" });
});

it("PS4: local references resolve β€” draft-7 uses definitions", function* () {
Expand All@@ -122,7 +135,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
const shape = shapeOf(schema);
expect(shape.user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -135,7 +148,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
};
expect(() => shapeOf(schema, "draft-7")).toThrow(/Reference not found/);
expect(shapeOf(schema, "draft-2020-12").user.safeParse({ name: "Ada" }).success).toBe(true);
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada" },
});
});
Expand All@@ -159,7 +172,7 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
data: { name: "Ada", role: "member" },
});
// ...whereas Ajv is the layer entitled to do so.
expect(validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
expect(yield* validateProps("x", { user: { name: "Ada" } }, schema)).toEqual({
user: { name: "Ada", role: "member" },
});
});
Expand All@@ -171,8 +184,8 @@ describe("Tier PS β€” JSON Schema to Standard Schema", () => {
success: true,
data: { a: "x", extra: 1 },
});
expect(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS)).toThrow(
/must NOT have additional properties/,
);
expect(
String(yield* raised(() => validateProps("x", { closed: { a: "y", extra: 1 } }, SCALARS))),
).toMatch(/must NOT have additional properties/);
});
});
28 changes: 19 additions & 9 deletions packages/core/src/component-failures.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -19,18 +19,28 @@ import type { ComponentFailure, ErrorSegment, FunctionComponent } from "./types.
import type { Operation } from "effection";

/**
* Components that continue after failing, remembered by function identity.
* The declaration a component wears to say it continues after failing.
*
* Identity rather than name: a repository component that happens to share a
* registered component's name is a different function and inherits nothing.
* This is the one thing about a component that is not run state. `printErrors(fn)`
* runs while a component module is evaluated β€” outside any operation, with no run
* to own a table and no scope to reach β€” and what it records is what an author
* declared about a function the author owns. So it lives on that function, which
* is where its lifetime already is.
*
* Module-private rather than `Symbol.for`, so nothing outside this module can
* forge the declaration, and non-enumerable, so a component that is copied,
* wrapped, or inspected does not carry it along by accident. Identity is what
* carries it either way: a repository component that happens to share a
* registered component's name is a different function object and inherits
* nothing.
*/
const printing = new WeakSet<FunctionComponent>();
const PRINTS_ERRORS = Symbol("executablemd.core.printsErrors");

/**
* Continue after this component fails, reporting the failure as a printed error.
*
* The component is returned unchanged β€” marking is membership, not wrapping β€”
* so its identity and type survive:
* The component is returned unchanged β€” declaring is marking, not wrapping β€” so
* its identity and type survive:
*
* ```ts
* export default printErrors(function* (props) {
Expand All@@ -43,12 +53,12 @@ const printing = new WeakSet<FunctionComponent>();
* projects is inside it.
*/
export function printErrors<T extends FunctionComponent>(component: T): T {
printing.add(component);
Object.defineProperty(component, PRINTS_ERRORS, { value: true, enumerable: false });
return component;
}

export function printsErrors(component: FunctionComponent): boolean {
return printing.has(component);
return Object.hasOwn(component, PRINTS_ERRORS);
}

/**
Expand DownExpand Up@@ -80,7 +90,7 @@ export function* usePrintErrors(): Operation<void> {
message: `Function component ${failure.name} error: ${failure.error.message}`,
source: failure.name,
};
attributeCause(segment, failure.error);
yield* attributeCause(segment, failure.error);
return yield* raise(segment);
},
});
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default function* Elicit(props: Record<string, Json>): Operation<Json> {
const prepared = prepareElicitation(props.schema);
const prepared = yield* prepareElicitation(props.schema);

const message = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/Parse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -44,7 +44,7 @@ export const props = {
export const returns = { $schema: "http://json-schema.org/draft-07/schema#" };

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("Parse", props.schema);
const validate = yield* compileParseSchema("Parse", props.schema);

const text = yield* content();

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/components/SafeParse.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -71,7 +71,7 @@ export const returns = {
};

export default printErrors(function* (props: Record<string, Json>): Operation<Json> {
const validate = compileParseSchema("SafeParse", props.schema);
const validate = yield* compileParseSchema("SafeParse", props.schema);

const text = yield* content();

Expand Down
55 changes: 42 additions & 13 deletions packages/core/src/components/parse-schema.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,23 +12,46 @@

import { Ajv } from "ajv";
import type { ValidateFunction } from "ajv";
import { Err, Ok } from "effection";
import type { Result } from "effection";
import { createContext, Err, Ok } from "effection";
import type { Context, Operation, Result } from "effection";
import { SchemaValidationError, normalizeIssues } from "../validate.ts";
import type { NormalizedIssue } from "../validate.ts";
import { parseJson, parseJsonObject } from "../json.ts";
import type { Json, JsonObject } from "../types.ts";

const ajv = new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
function createParseCompiler(): Ajv {
return new Ajv({
strict: true,
allErrors: true,
validateSchema: true,
useDefaults: false,
coerceTypes: false,
removeAdditional: false,
addUsedSchema: false,
validateFormats: false,
});
}

/**
* The compiler one execution parses with.
*
* Scoped for the same reason the props compiler is (see `validate.ts`): Ajv
* remembers every compile in a `Map` keyed by the schema object, so an instance
* that outlived a run would accumulate a run's worth of schemas per run and
* answer a mutated schema object with the previous run's validator. A document
* brings fresh schema objects every time.
*/
const ParseCompiler: Context<Ajv | undefined> = createContext<Ajv | undefined>(
"component.parseCompiler",
undefined,
);

/** Open the parse compiler for one execution. */
export function* useParseCompiler(): Operation<Ajv> {
const compiler = createParseCompiler();
yield* ParseCompiler.set(compiler);
return compiler;
}

/** A schema that could not be read or compiled. Raised before any child runs. */
export class ParseSchemaError extends Error {
Expand DownExpand Up@@ -65,8 +88,14 @@ function headline(componentName: string, issues: NormalizedIssue[]): string {
* draft-07 compilation, so a document can hold its schema in a code fence or in
* a binding and get identical behavior.
*/
export function compileParseSchema(componentName: string, schema: Json): ValidateFunction {
export function* compileParseSchema(
componentName: string,
schema: Json,
): Operation<ValidateFunction> {
const declaration = readSchema(componentName, schema);
// Without a run there is nothing to reclaim: the compiler lives exactly as
// long as this call.
const ajv = (yield* ParseCompiler.get()) ?? createParseCompiler();

// Ajv does not reject an async schema β€” it compiles a validator that returns
// a promise. Reject it before and after compiling so validation stays
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/components/registration.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -201,9 +201,9 @@ export function* registerComponents(
`the registration for "${name}" needs an origin naming where it came from`,
);
}
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
assertUsableCaptures(name, registration.captures, props);

Expand Down
9 changes: 5 additions & 4 deletions packages/core/src/definition.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
import type { Operation } from "effection";
import type { ComponentDefinition } from "./types.ts";
import { parseFrontmatter } from "./frontmatter.ts";
import { compilePropsSchema, compileReturnsSchema } from "./validate.ts";
Expand All@@ -20,16 +21,16 @@ export function isFunctionComponentPath(path: string): boolean {
* drift: both compile the props and return schemas, so a malformed schema
* fails the same way whether the document runs or is only described.
*/
export function parseMarkdownDefinition(
export function* parseMarkdownDefinition(
name: string,
path: string,
content: string,
): ComponentDefinition {
): Operation<ComponentDefinition> {
const parsed = matter(content);
const { meta, props, returns } = parseFrontmatter(parsed.data);
compilePropsSchema(props);
yield* compilePropsSchema(props);
if (returns !== undefined) {
compileReturnsSchema(returns);
yield* compileReturnsSchema(returns);
}
// The markdown body is a verbatim suffix of the raw file, so the body start
// is computed by length β€” never by content search, which could false-match
Expand Down
13 changes: 8 additions & 5 deletions packages/core/src/elicit.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,16 +62,16 @@ export interface PreparedElicitation {
* Synchronous and effect-free: it either produces a question that can be asked
* or throws, and a caller that has not yet begun anything can still stop.
*/
export function prepareElicitation(
export function* prepareElicitation(
schema: Json,
label: string = DEFAULT_LABEL,
): PreparedElicitation {
): Operation<PreparedElicitation> {
const declaration = readParseSchema(label, schema);

refuseUnsupportedNames(label, declaration);
refuseExternalReferences(label, declaration);

return { schema: declaration, validate: compileParseSchema(label, declaration), label };
return { schema: declaration, validate: yield* compileParseSchema(label, declaration), label };
}

/** Ask the configured provider, and judge what it returns. */
Expand All@@ -94,12 +94,15 @@ export function* runPreparedElicitation(
}

/** Compile and ask in one step, for a host with no ordering of its own. */
export function elicit(request: {
export function* elicit(request: {
message: string;
schema: Json;
label?: string;
}): Operation<Json> {
return runPreparedElicitation(prepareElicitation(request.schema, request.label), request.message);
return yield* runPreparedElicitation(
yield* prepareElicitation(request.schema, request.label),
request.message,
);
}

/**
Expand Down
Loading
Loading