1
0
Fork 0
mirror of https://github.com/denoland/deno.git synced 2024-11-21 15:04:11 -05:00

feat: serialize JSX for console

This commit is contained in:
Marvin Hagemeister 2023-09-29 18:57:08 +02:00
parent 5edd102f3f
commit d1677a1a8f
5 changed files with 531 additions and 1 deletions

View file

@ -1,6 +1,11 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/// <reference path="../../core/internal.d.ts" />
import {
preactAdapter,
reactAdapter,
serialize,
} from "ext:deno_console/02_jsx.js";
const core = globalThis.Deno.core;
const internals = globalThis.__bootstrap.internals;
@ -169,6 +174,10 @@ const styles = {
regexp: "red",
module: "underline",
internalError: "red",
jsxElement: "green",
jsxComponent: "magenta",
jsxAttribute: "yellow",
jsxOther: "reset",
};
const defaultFG = 39;
@ -919,12 +928,26 @@ function formatRaw(ctx, value, recurseTimes, typedArray, proxyDetails) {
if (noIterator) {
keys = getKeys(value, ctx.showHidden);
braces = ["{", "}"];
// Preact JSX
if (
constructor === undefined && value !== null && typeof value === "object"
) {
return serialize(ctx, preactAdapter, value, ctx.indentationLvl, 10);
}
if (constructor === "Object") {
if (isArgumentsObject(value)) {
braces[0] = "[Arguments] {";
} else if (tag !== "") {
braces[0] = `${getPrefix(constructor, tag, "Object")}{`;
}
// React JSX
if ("$$typeof" in value && typeof value.$$typeof === "symbol") {
return serialize(ctx, reactAdapter, value, ctx.indentationLvl, 10);
}
if (keys.length === 0 && protoProps === undefined) {
return `${braces[0]}}`;
}

431
ext/console/02_jsx.js Normal file
View file

@ -0,0 +1,431 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
/// <reference path="../../core/internal.d.ts" />
const primordials = globalThis.__bootstrap.primordials;
const {
Array,
ArrayIsArray,
ArrayPrototypePush,
ArrayPrototypePushApply,
ObjectKeys,
String,
StringPrototypeRepeat,
StringPrototypeSlice,
SymbolFor,
} = primordials;
/**
* React adapter to serialize JSX to string
* @type {import("ext:deno_console/jsx").JSXSerializeAdapter}
*/
export const reactAdapter = {
getName(vnode) {
if (typeof vnode.type === "string") return vnode.type;
if (typeof vnode.type === "function") {
return vnode.type.displayName || vnode.type.name || "Anonymous";
}
if (vnode.type == SymbolFor("react.fragment")) return "Fragment";
if (vnode.type == SymbolFor("react.suspense")) return "Suspense";
if (vnode.type == SymbolFor("react.strict_mode")) return "StrictMode";
if (vnode.type == SymbolFor("react.profiler")) return "Profiler";
if (
vnode.type !== null &&
typeof vnode.type === "object" &&
typeof vnode.type.$$typeof === "symbol"
) {
const $$typeof = vnode.type.$$typeof;
if ($$typeof === SymbolFor("react.provider")) {
const suffix = vnode.type.displayName
? "." + vnode.type.displayName
: "";
return `Provider${suffix}`;
} else if ($$typeof === SymbolFor("react.context")) {
const suffix = vnode.type.displayName
? "." + vnode.type.displayName
: "";
return `Consumer${suffix}`;
} else if (
$$typeof === SymbolFor("react.memo") &&
typeof vnode.type.type === "function"
) {
const fnName = vnode.type.type.displayName || vnode.type.type.name ||
"";
return `Memo${fnName ? `(${fnName})` : ""}`;
} else if ($$typeof === SymbolFor("react.forward_ref")) {
const name = vnode.type.render.displayName ||
vnode.type.render.name ||
"Anonymous";
return `ForwardRef(${name})`;
}
}
return "Unknown";
},
getTextIfTextNode() {
// React doesn't have proper text elements afaik
return null;
},
isFragment(vnode) {
return vnode.type === SymbolFor("react.fragment");
},
};
/**
* Preact adapter to serialize JSX to string
* @type {import("ext:deno_console/jsx").JSXSerializeAdapter}
*/
export const preactAdapter = {
getName(vnode) {
if (typeof vnode.type === "string") return vnode.type;
if (typeof vnode.type === "function") {
const name = vnode.type.displayName || vnode.type.name || "Anonymous";
// TODO: Fragments can only be detected by importing them, this is
// a hacky workaround
if (name === "k") return "Fragment";
if ("contextType" in vnode.type) {
const ct = vnode.type.contextType;
const name = vnode.type === ct.Consumer ? "Consumer" : "Provider";
const suffix = ct.displayName ? `.${ct.displayName}` : "";
return name + suffix;
}
return name;
}
return "Unknown";
},
getTextIfTextNode(vnode) {
return vnode.type === null ? String(vnode.props.data) : null;
},
isFragment(vnode) {
// TODO: Fragments can only be detected by importing them, this is
// a hacky workaround
return typeof vnode.type === "function" && vnode.type.name === "k";
},
isValidElement(value) {
return value !== null && typeof value === "object";
},
};
function isVNode(x) {
return (
x !== null &&
typeof x === "object" &&
"type" in x &&
"props" in x &&
"key" in x
);
}
/**
* @param {number} n
* @returns {string}
*/
function indent(n) {
if (n === 0) return "";
return StringPrototypeRepeat(" ", n);
}
const OTHER = "jsxOther";
const ELEMENT = "jsxElement";
const COMPONENT = "jsxComponent";
const SPECIAL = "special";
const ATTR = "jsxAttribute";
/**
* Serialize JSX to a pretty formatted string
* @param {{ stylize: (str: string, color: string) => string }} ctx
* @param {import("ext:deno_console/jsx").JSXSerializeAdapter} adapter
* @param {import("ext:deno_console/jsx").SharedVNode} vnode
* @param {number} level
* @param {number} limit
* @returns {string}
*/
export function serialize(ctx, adapter, vnode, level, limit) {
const space = indent(level);
const text = adapter.getTextIfTextNode(vnode);
if (text !== null) {
return space + text;
}
const isKeyed = vnode.key !== null && vnode.key !== undefined;
const isFragment = adapter.isFragment(vnode);
const isDomNode = !isFragment && typeof vnode.type === "string";
let namePretty;
let TAGS = ELEMENT;
// Fragments are a special case since they have special nameless
// syntax `<>`. They can only have a `key` prop in React, but Preact
// doesn't have that restriction
if (isFragment) {
TAGS = isKeyed ? COMPONENT : ELEMENT;
namePretty = isKeyed ? ctx.stylize("Fragment", COMPONENT) : "";
} else {
const name = adapter.getName(vnode);
// Preact text node
if (name === null) {
return space + vnode.props.data;
}
if (isDomNode) {
TAGS = ELEMENT;
namePretty = ctx.stylize(name, ELEMENT);
} else {
TAGS = COMPONENT;
namePretty = ctx.stylize(name, COMPONENT);
}
}
let out = space + ctx.stylize("<", TAGS) + namePretty;
if (isKeyed) {
const value = ctx.stylize(`"${String(vnode.key)}"`, "string");
out += ` ${ctx.stylize("key", ATTR)}${ctx.stylize("=", OTHER)}${value}`;
}
out += serializeProps(
ctx,
adapter,
vnode.props,
isDomNode,
level + 1 > limit,
);
/** @type {import("ext:deno_console/jsx").NormalizedChild} */
const children = [];
/** @type {import("ext:deno_console/jsx").VElement} */
const rawChildren = vnode.props.children;
normalizeChildren(children, rawChildren, 0);
if (children.length > 0) {
const singleChild = !ArrayIsArray(vnode.props.children);
out += ctx.stylize(">", TAGS);
// Single text child may be formatted in same line
if (level + 1 > limit) {
if (children.length > 0) {
out += "...";
}
} else if (children.length === 1 && typeof children[0] === "string") {
const str = children[0];
const fitsSameLine = str.length < 40;
out += fitsSameLine ? str : indent(level + 1) + str + "\n";
} else if (children.length === 1 && typeof children[0] === "function") {
const fn = children[0];
out += ctx.stylize("{", OTHER) +
ctx.stylize(`[Function: ${fn.name || "Anonymous"}]`, SPECIAL) +
ctx.stylize("}", OTHER);
} else {
out += "\n";
const unwrapped = singleChild ? children[0] : children;
if (ArrayIsArray(unwrapped)) {
for (let i = 0; i < unwrapped.length; i++) {
out += serializeChildren(ctx, adapter, unwrapped[i], level, limit);
}
} else {
out += serializeChildren(ctx, adapter, unwrapped, level, limit);
}
out += indent(level);
}
out += ctx.stylize("</", TAGS) + namePretty +
ctx.stylize(">", TAGS);
} else if (isFragment && !isKeyed) {
out += ctx.stylize("></>", TAGS);
} else {
out += ctx.stylize(" />", TAGS);
}
if (level > 0 && level + 1 < limit) {
out += "\n";
}
return out;
}
/**
* @param {{ stylize: (str: string, color: string) => string }} ctx
* @param {import("ext:deno_console/jsx").JSXSerializeAdapter} adapter
* @param {import("ext:deno_console/jsx".NormalizedChild)} child
* @param {number} level
* @param {number} limit
* @returns
*/
function serializeChildren(
ctx,
adapter,
child,
level,
limit,
) {
let out = "";
if (typeof child === "string") {
return indent(level + 1) + child + "\n";
} else if (typeof child === "function") {
return (
indent(level + 1) +
ctx.stylize("{", OTHER) +
ctx.stylize(`[Function: ${child.name || "Anonymous"}]`, SPECIAL) +
ctx.stylize("}", OTHER) +
"\n"
);
} else if (Array.isArray(child)) {
out += indent(level + 1) + "[\n";
for (let i = 0; i < child.length; i++) {
const actual = child[i];
out += serializeChildren(ctx, adapter, actual, level + 1, limit);
out = StringPrototypeSlice(out, 0, -1) + ctx.stylize(",\n", OTHER);
}
out += indent(level + 1) + "]\n";
} else {
out += serialize(ctx, adapter, child, level + 1, limit);
}
return out;
}
/**
* Flatten children into an array. Nested arrays are flattened and some
* values like null + undefined or booleans are filtered out.
* @param {import("ext:deno_console/jsx").NormalizedChild[]} out
* @param {import("ext:deno_console/jsx").VElement} child
* @param {number} level
*/
function normalizeChildren(
out,
child,
level,
) {
// These are ignored as children
if (child === null || child === undefined || typeof child === "boolean") {
return;
}
// Preserve nested arrays. Frameworks typically replace that with an
// implicit Fragment, but showing that would be more confusing than
// showing the array
if (ArrayIsArray(child)) {
/** @type {import("ext:deno_console/jsx").NormalizedChild[]} */
const out2 = [];
for (let i = 0; i < child.length; i++) {
normalizeChildren(out2, child[i], level + 1);
}
if (out2.length > 0) {
if (out2.length === 1 && typeof out2[0] === "string") {
if (out.length > 0) {
const last = out[out.length - 1];
if (typeof last === "string") {
out[out.length - 1] += out2[0];
return;
}
}
ArrayPrototypePushApply(out, out2);
} else if (level === 0) {
ArrayPrototypePushApply(out, out2);
} else {
ArrayPrototypePush(out, out2);
}
}
return;
} else if (typeof child === "function") {
ArrayPrototypePush(out, child);
return;
}
// Check if child can be merged into previous child
if (out.length > 0 && !isVNode(child)) {
const last = out[out.length - 1];
if (typeof last === "string") {
out[out.length - 1] += String(child);
return;
}
}
ArrayPrototypePush(out, isVNode(child) ? child : String(child));
}
/**
* Serialize JSX props
* @param {{ stylize: (str: string, color: string) => string }} ctx
* @param {import("ext:deno_console/jsx").JSXSerializeAdapter} adapter
* @param {Record<string, unknown>} props
* @param {boolean} isDomNode
* @param {boolean} skipVNodeSerialisation
* @returns {string}
*/
function serializeProps(
ctx,
adapter,
props,
isDomNode,
skipVNodeSerialisation,
) {
// TODO: Primodals
const sorted = ObjectKeys(props).sort((a, b) => a.localeCompare(b));
let out = "";
for (let i = 0; i < sorted.length; i++) {
const name = sorted[i];
const value = props[name];
// Empty values for DOM nodes
if (
name === "children" ||
name === "key" ||
name === "ref" ||
value === undefined ||
(isDomNode && value === null)
) {
continue;
} else if (typeof value === "string") {
out += ` ${ctx.stylize(name, ATTR)}${
ctx.stylize("=", OTHER)
}${ctx.stylize(`"${value}"`), "green"}`;
} else {
// Complex types
out += ` ${ctx.stylize(name, ATTR)}`;
// Truthy boolean values are usually displayed without value
if (value === true) {
continue;
}
out += ctx.stylize("={", OTHER);
if (ArrayIsArray(value)) {
out += ctx.stylize(`Array(${value.length})`, SPECIAL);
} else if (value instanceof Map) {
out += ctx.stylize(`Map(${value.size})`, SPECIAL);
} else if (value instanceof Set) {
out += ctx.stylize(`Set(${value.size})`, SPECIAL);
} else if (typeof value === "function") {
out += ctx.stylize(
`[Function: ${value.name || "Anonymous"}]`,
SPECIAL,
);
} else if (isVNode(value)) {
if (skipVNodeSerialisation) {
out += ctx.stylize("...", OTHER);
} else {
out += serialize(ctx, adapter, value, 0, 0);
}
} else {
out += ctx.stylize(String(value), SPECIAL);
}
out += ctx.stylize("}", OTHER);
}
}
return out;
}

View file

@ -10,3 +10,78 @@ declare module "ext:deno_console/01_console.js" {
evaluate: boolean;
}): Record<string, unknown>;
}
declare module "ext:deno_console/02_jsx.js" {
import { JSXSerializeAdapter, SharedVNode } from "ext:deno_console/jsx";
export const reactAdapter: JSXSerializeAdapter;
export const preactAdapter: JSXSerializeAdapter;
export function serialize(
adapter: JSXSerializeAdapter,
vnode: SharedVNode,
level: number,
limit: number
): string;
}
declare module "ext:deno_console/jsx" {
export interface ComponentFunction<T = unknown> {
(props: T): VElement;
displayName?: string;
contenxtType?: {
displayName?: string;
Provider?: (props: unknown) => VElement;
Consumer?: (props: unknown) => VElement;
};
}
export type VChild =
| string
| number
| null
| undefined
// deno-lint-ignore ban-types
| Function
| SharedVNode;
export type VElement = VChild | VElement[] | Iterable<VElement>;
export type NormalizedChild =
| string
// deno-lint-ignore ban-types
| Function
| SharedVNode
| NormalizedChild[];
export interface PreactComponentInstance {
sub?: unknown;
displayName?: string;
}
export interface SharedVNode {
$$typeof?: symbol;
// In Preact "null" means text and "props.data" is the text.
// In React symbols are for special nodes like Fragments
type:
| string
| ComponentFunction
| null
| number
| symbol
// React
| {
$$typeof: symbol;
displayName?: string;
type: ComponentFunction;
render?: ComponentFunction;
};
props: Record<string, unknown>;
key: null | undefined | number | string;
}
export interface JSXSerializeAdapter {
getTextIfTextNode(x: SharedVNode): string | null;
isFragment(x: SharedVNode): boolean;
getName(x: SharedVNode): string;
isValidElement(x: unknown): x is SharedVNode;
}
}

View file

@ -1,7 +1,7 @@
// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license.
use std::path::PathBuf;
deno_core::extension!(deno_console, esm = ["01_console.js"],);
deno_core::extension!(deno_console, esm = ["01_console.js", "02_jsx.js"],);
pub fn get_declaration() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("lib.deno_console.d.ts")

View file

@ -3,6 +3,7 @@
"ext:deno_broadcast_channel/01_broadcast_channel.js": "../ext/broadcast_channel/01_broadcast_channel.js",
"ext:deno_cache/01_cache.js": "../ext/cache/01_cache.js",
"ext:deno_console/01_console.js": "../ext/console/01_console.js",
"ext:deno_console/02_jsx.js": "../ext/console/02_jsx.js",
"ext:deno_crypto/00_crypto.js": "../ext/crypto/00_crypto.js",
"ext:deno_fetch/20_headers.js": "../ext/fetch/20_headers.js",
"ext:deno_fetch/21_formdata.js": "../ext/fetch/21_formdata.js",