diff --git a/package-lock.json b/package-lock.json index bebd51b..8b8a620 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "license": "ISC", "dependencies": { "codemirror": "^5.54.0", - "codemirror-graphql": "1.3.2", "prop-types": "^15.7.2", "react-codemirror2": "^8.0.0" }, @@ -1838,40 +1837,6 @@ "specificity": "bin/cli.js" } }, - "node_modules/@codemirror/language": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-0.20.2.tgz", - "integrity": "sha512-WB3Bnuusw0xhVvhBocieYKwJm04SOk5bPoOEYksVHKHcGHFOaYaw+eZVxR4gIqMMcGzOIUil0FsCmFk8yrhHpw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@codemirror/state": "^0.20.0", - "@codemirror/view": "^0.20.0", - "@lezer/common": "^0.16.0", - "@lezer/highlight": "^0.16.0", - "@lezer/lr": "^0.16.0", - "style-mod": "^4.0.0" - } - }, - "node_modules/@codemirror/state": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-0.20.1.tgz", - "integrity": "sha512-ms0tlV5A02OK0pFvTtSUGMLkoarzh1F8mr6jy1cD7ucSC2X/VLHtQCxfhdSEGqTYlQF2hoZtmLv+amqhdgbwjQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@codemirror/view": { - "version": "0.20.7", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-0.20.7.tgz", - "integrity": "sha512-pqEPCb9QFTOtHgAH5XU/oVy9UR/Anj6r+tG5CRmkNVcqSKEPmBU05WtN/jxJCFZBXf6HumzWC9ydE4qstO3TxQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@codemirror/state": "^0.20.0", - "style-mod": "^4.0.0", - "w3c-keyname": "^2.2.4" - } - }, "node_modules/@csstools/color-helpers": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", @@ -2367,33 +2332,6 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", "dev": true }, - "node_modules/@lezer/common": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-0.16.1.tgz", - "integrity": "sha512-qPmG7YTZ6lATyTOAWf8vXE+iRrt1NJd4cm2nJHK+v7X9TsOF6+HtuU/ctaZy2RCrluxDb89hI6KWQ5LfQGQWuA==", - "license": "MIT", - "peer": true - }, - "node_modules/@lezer/highlight": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-0.16.0.tgz", - "integrity": "sha512-iE5f4flHlJ1g1clOStvXNLbORJoiW4Kytso6ubfYzHnaNo/eo5SKhxs4wv/rtvwZQeZrK3we8S9SyA7OGOoRKQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@lezer/common": "^0.16.0" - } - }, - "node_modules/@lezer/lr": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-0.16.3.tgz", - "integrity": "sha512-pau7um4eAw94BEuuShUIeQDTf3k4Wt6oIUOYxMmkZgDHdqtIcxWND4LRxi8nI9KuT4I1bXQv67BCapkxt7Ywqw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@lezer/common": "^0.16.0" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -6149,20 +6087,6 @@ "integrity": "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ==", "license": "MIT" }, - "node_modules/codemirror-graphql": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/codemirror-graphql/-/codemirror-graphql-1.3.2.tgz", - "integrity": "sha512-glwFsEVlH5TvxjSKGymZ1sNy37f3Mes58CB4fXOd0zy9+JzDL08Wti1b5ycy4vFZYghMDK1/Or/zRSjMAGtC2w==", - "license": "MIT", - "dependencies": { - "graphql-language-service": "^5.0.6" - }, - "peerDependencies": { - "@codemirror/language": "^0.20.0", - "codemirror": "^5.65.3", - "graphql": "^15.5.0 || ^16.0.0" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -6461,12 +6385,6 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/debounce-promise": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/debounce-promise/-/debounce-promise-3.1.2.tgz", - "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -7598,33 +7516,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/graphql": { - "version": "16.12.0", - "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.12.0.tgz", - "integrity": "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" - } - }, - "node_modules/graphql-language-service": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/graphql-language-service/-/graphql-language-service-5.5.0.tgz", - "integrity": "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q==", - "license": "MIT", - "dependencies": { - "debounce-promise": "^3.1.2", - "nullthrows": "^1.0.0", - "vscode-languageserver-types": "^3.17.1" - }, - "bin": { - "graphql": "dist/temp-bin.js" - }, - "peerDependencies": { - "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/handle-thing": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", @@ -9415,12 +9306,6 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, - "node_modules/nullthrows": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", - "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", - "license": "MIT" - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11247,13 +11132,6 @@ "webpack": "^5.27.0" } }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT", - "peer": true - }, "node_modules/supports-color": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz", @@ -11952,19 +11830,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/vscode-languageserver-types": { - "version": "3.17.5", - "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", - "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", - "license": "MIT" - }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", - "license": "MIT", - "peer": true - }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index cfb1f98..3e844d9 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,10 @@ "type": "git", "url": "https://github.com/readmeio/syntax-highlighter.git" }, + "files": [ + "dist", + "dist-utils" + ], "main": "dist/index.node.js", "browser": "dist/index.js", "exports": { @@ -39,7 +43,6 @@ }, "dependencies": { "codemirror": "^5.54.0", - "codemirror-graphql": "1.3.2", "prop-types": "^15.7.2", "react-codemirror2": "^8.0.0" }, diff --git a/src/codeMirror/index.jsx b/src/codeMirror/index.jsx index 23f47e8..4a7dad8 100644 --- a/src/codeMirror/index.jsx +++ b/src/codeMirror/index.jsx @@ -5,7 +5,6 @@ import 'codemirror/addon/fold/foldgutter'; import 'codemirror/addon/runmode/runmode'; import 'codemirror/addon/scroll/simplescrollbars'; import 'codemirror/addon/scroll/simplescrollbars.css'; -import 'codemirror/mode/meta'; import PropTypes from 'prop-types'; import React from 'react'; diff --git a/src/utils/cm-mode-imports.js b/src/utils/cm-mode-imports.js index 4c0b7ee..ffc67cd 100644 --- a/src/utils/cm-mode-imports.js +++ b/src/utils/cm-mode-imports.js @@ -29,5 +29,5 @@ require('codemirror/mode/sql/sql'); require('codemirror/mode/swift/swift'); require('codemirror/mode/toml/toml'); require('codemirror/mode/yaml/yaml'); -require('codemirror-graphql/mode'); +require('./modes/graphql/mode'); require('./modes/solidity'); diff --git a/src/utils/modes/graphql/RuleHelpers.js b/src/utils/modes/graphql/RuleHelpers.js new file mode 100644 index 0000000..248dd03 --- /dev/null +++ b/src/utils/modes/graphql/RuleHelpers.js @@ -0,0 +1,47 @@ +// oxlint-disable no-param-reassign + +/** + * Vendored from `graphql-language-service`'s `esm/parser/RuleHelpers.js`, stripped of its + * TypeScript types. + * + * @license MIT + * @see {@link https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/parser/RuleHelpers.ts} + */ + +// These functions help build matching rules for ParseRules. + +// An optional rule. +export function opt(ofRule) { + return { ofRule }; +} + +// A list of another rule. +export function list(ofRule, separator) { + return { ofRule, isList: true, separator }; +} + +// A constraint described as `but not` in the GraphQL spec. +export function butNot(rule, exclusions) { + const ruleMatch = rule.match; + rule.match = token => { + let check = false; + if (ruleMatch) { + check = ruleMatch(token); + } + return check && exclusions.every(exclusion => exclusion.match && !exclusion.match(token)); + }; + return rule; +} + +// Token of a kind +export function t(kind, style) { + return { style, match: token => token.kind === kind }; +} + +// Punctuator +export function p(value, style) { + return { + style: style || 'punctuation', + match: token => token.kind === 'Punctuation' && token.value === value, + }; +} diff --git a/src/utils/modes/graphql/Rules.js b/src/utils/modes/graphql/Rules.js new file mode 100644 index 0000000..f23e41b --- /dev/null +++ b/src/utils/modes/graphql/Rules.js @@ -0,0 +1,285 @@ +// oxlint-disable no-param-reassign +// oxlint-disable unicorn/no-useless-undefined + +/** + * Vendored from `graphql-language-service`'s `esm/parser/Rules.js`, stripped of its TypeScript + * types. The only thing this file needed from the `graphql` package was a handful of `Kind` + * string constants (`Document`, `FragmentDefinition`, `SchemaExtension`, etc.), which we've + * inlined below instead of pulling in all of `graphql` (and its validation rules) just for a mode. + * + * @license MIT + * @see {@link https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/parser/Rules.ts} + */ +import { opt, list, butNot, t, p } from './RuleHelpers.js'; + +/** + * Whitespace tokens defined in GraphQL spec. + */ +export const isIgnored = ch => + ch === ' ' || ch === '\t' || ch === ',' || ch === '\n' || ch === '\r' || ch === '\uFEFF' || ch === '\u00A0'; + +/** + * The lexer rules. These are exactly as described by the spec. + */ +export const LexRules = { + // The Name token. + Name: /^[_A-Za-z][_0-9A-Za-z]*/, + + // All Punctuation used in GraphQL + Punctuation: /^(?:!|\$|\(|\)|\.\.\.|:|=|&|@|\[|]|\{|\||\})/, + + // Combines the IntValue and FloatValue tokens. + Number: /^-?(?:0|(?:[1-9][0-9]*))(?:\.[0-9]*)?(?:[eE][+-]?[0-9]+)?/, + + // Note the closing quote is made optional as an IDE experience improvement. + String: /^(?:"""(?:\\"""|[^"]|"[^"]|""[^"])*(?:""")?|"(?:[^"\\]|\\(?:"|\/|\\|b|f|n|r|t|u[0-9a-fA-F]{4}))*"?)/, + + // Comments consume entire lines. + Comment: /^#.*/, +}; + +/** + * The parser rules. These are very close to, but not exactly the same as the + * spec. Minor deviations allow for a simpler implementation. The resulting + * parser can parse everything the spec declares possible. + */ +export const ParseRules = { + Document: [list('Definition')], + Definition(token) { + switch (token.value) { + case '{': + return 'ShortQuery'; + case 'query': + return 'Query'; + case 'mutation': + return 'Mutation'; + case 'subscription': + return 'Subscription'; + case 'fragment': + return 'FragmentDefinition'; + case 'schema': + return 'SchemaDef'; + case 'scalar': + return 'ScalarDef'; + case 'type': + return 'ObjectTypeDef'; + case 'interface': + return 'InterfaceDef'; + case 'union': + return 'UnionDef'; + case 'enum': + return 'EnumDef'; + case 'input': + return 'InputDef'; + case 'extend': + return 'ExtendDef'; + case 'directive': + return 'DirectiveDef'; + default: + return undefined; + } + }, + // Note: instead of "Operation", these rules have been separated out. + ShortQuery: ['SelectionSet'], + Query: [word('query'), opt(name('def')), opt('VariableDefinitions'), list('Directive'), 'SelectionSet'], + + Mutation: [word('mutation'), opt(name('def')), opt('VariableDefinitions'), list('Directive'), 'SelectionSet'], + + Subscription: [word('subscription'), opt(name('def')), opt('VariableDefinitions'), list('Directive'), 'SelectionSet'], + + VariableDefinitions: [p('('), list('VariableDefinition'), p(')')], + VariableDefinition: ['Variable', p(':'), 'Type', opt('DefaultValue')], + Variable: [p('$', 'variable'), name('variable')], + DefaultValue: [p('='), 'Value'], + SelectionSet: [p('{'), list('Selection'), p('}')], + Selection(token, stream) { + return token.value === '...' + ? stream.match(/[\s\u00a0,]*(on\b|@|{)/, false) + ? 'InlineFragment' + : 'FragmentSpread' + : stream.match(/[\s\u00a0,]*:/, false) + ? 'AliasedField' + : 'Field'; + }, + // Note: this minor deviation of "AliasedField" simplifies the lookahead. + AliasedField: [name('property'), p(':'), name('qualifier'), opt('Arguments'), list('Directive'), opt('SelectionSet')], + + Field: [name('property'), opt('Arguments'), list('Directive'), opt('SelectionSet')], + + Arguments: [p('('), list('Argument'), p(')')], + Argument: [name('attribute'), p(':'), 'Value'], + FragmentSpread: [p('...'), name('def'), list('Directive')], + InlineFragment: [p('...'), opt('TypeCondition'), list('Directive'), 'SelectionSet'], + + FragmentDefinition: [ + word('fragment'), + opt(butNot(name('def'), [word('on')])), + 'TypeCondition', + list('Directive'), + 'SelectionSet', + ], + + TypeCondition: [word('on'), 'NamedType'], + // Variables could be parsed in cases where only Const is expected by spec. + Value(token) { + switch (token.kind) { + case 'Number': + return 'NumberValue'; + case 'String': + return 'StringValue'; + case 'Punctuation': + switch (token.value) { + case '[': + return 'ListValue'; + case '{': + return 'ObjectValue'; + case '$': + return 'Variable'; + case '&': + return 'NamedType'; + default: + return null; + } + case 'Name': + switch (token.value) { + case 'true': + case 'false': + return 'BooleanValue'; + default: + break; + } + + if (token.value === 'null') { + return 'NullValue'; + } + return 'EnumValue'; + default: + return undefined; + } + }, + NumberValue: [t('Number', 'number')], + StringValue: [ + { + style: 'string', + match: token => token.kind === 'String', + update(state, token) { + if (token.value.startsWith('"""')) { + state.inBlockstring = !token.value.slice(3).endsWith('"""'); + } + }, + }, + ], + BooleanValue: [t('Name', 'builtin')], + NullValue: [t('Name', 'keyword')], + EnumValue: [name('string-2')], + ListValue: [p('['), list('Value'), p(']')], + ObjectValue: [p('{'), list('ObjectField'), p('}')], + ObjectField: [name('attribute'), p(':'), 'Value'], + Type(token) { + return token.value === '[' ? 'ListType' : 'NonNullType'; + }, + // NonNullType has been merged into ListType to simplify. + ListType: [p('['), 'Type', p(']'), opt(p('!'))], + NonNullType: ['NamedType', opt(p('!'))], + NamedType: [type('atom')], + Directive: [p('@', 'meta'), name('meta'), opt('Arguments')], + DirectiveDef: [ + word('directive'), + p('@', 'meta'), + name('meta'), + opt('ArgumentsDef'), + word('on'), + list('DirectiveLocation', p('|')), + ], + InterfaceDef: [ + word('interface'), + name('atom'), + opt('Implements'), + list('Directive'), + p('{'), + list('FieldDef'), + p('}'), + ], + Implements: [word('implements'), list('NamedType', p('&'))], + DirectiveLocation: [name('string-2')], + // GraphQL schema language + SchemaDef: [word('schema'), list('Directive'), p('{'), list('OperationTypeDef'), p('}')], + + OperationTypeDef: [name('keyword'), p(':'), name('atom')], + ScalarDef: [word('scalar'), name('atom'), list('Directive')], + ObjectTypeDef: [word('type'), name('atom'), opt('Implements'), list('Directive'), p('{'), list('FieldDef'), p('}')], + + FieldDef: [name('property'), opt('ArgumentsDef'), p(':'), 'Type', list('Directive')], + + ArgumentsDef: [p('('), list('InputValueDef'), p(')')], + InputValueDef: [name('attribute'), p(':'), 'Type', opt('DefaultValue'), list('Directive')], + + UnionDef: [word('union'), name('atom'), list('Directive'), p('='), list('UnionMember', p('|'))], + + UnionMember: ['NamedType'], + EnumDef: [word('enum'), name('atom'), list('Directive'), p('{'), list('EnumValueDef'), p('}')], + + EnumValueDef: [name('string-2'), list('Directive')], + InputDef: [word('input'), name('atom'), list('Directive'), p('{'), list('InputValueDef'), p('}')], + ExtendDef: [word('extend'), 'ExtensionDefinition'], + ExtensionDefinition(token) { + switch (token.value) { + case 'schema': + return 'SchemaExtension'; + case 'scalar': + return 'ScalarTypeExtension'; + case 'type': + return 'ObjectTypeExtension'; + case 'interface': + return 'InterfaceTypeExtension'; + case 'union': + return 'UnionTypeExtension'; + case 'enum': + return 'EnumTypeExtension'; + case 'input': + return 'InputObjectTypeExtension'; + default: + return undefined; + } + }, + SchemaExtension: ['SchemaDef'], + ScalarTypeExtension: ['ScalarDef'], + ObjectTypeExtension: ['ObjectTypeDef'], + InterfaceTypeExtension: ['InterfaceDef'], + UnionTypeExtension: ['UnionDef'], + EnumTypeExtension: ['EnumDef'], + InputObjectTypeExtension: ['InputDef'], +}; + +// A keyword Token. +function word(value) { + return { + style: 'keyword', + match: token => token.kind === 'Name' && token.value === value, + }; +} + +// A Name Token which will decorate the state with a `name`. +function name(style) { + return { + style, + match: token => token.kind === 'Name', + update(state, token) { + state.name = token.value; + }, + }; +} + +// A Name Token which will decorate the previous state with a `type`. +function type(style) { + return { + style, + match: token => token.kind === 'Name', + update(state, token) { + if (state.prevState?.prevState) { + state.name = token.value; + state.prevState.prevState.type = token.value; + } + }, + }; +} diff --git a/src/utils/modes/graphql/mode-factory.js b/src/utils/modes/graphql/mode-factory.js new file mode 100644 index 0000000..f6116fc --- /dev/null +++ b/src/utils/modes/graphql/mode-factory.js @@ -0,0 +1,55 @@ +/** + * Vendored from `codemirror-graphql`'s `esm/utils/mode-factory.js`, stripped of its TypeScript + * types. + * + * @license MIT + * @see {@link https://github.com/graphql/graphiql/blob/main/packages/codemirror-graphql/src/utils/mode-factory.ts} + */ +import indent from './mode-indent.js'; +import onlineParser from './onlineParser.js'; +import { LexRules, ParseRules, isIgnored } from './Rules.js'; + +/** + * The GraphQL mode is defined as a tokenizer along with a list of rules, each + * of which is either a function or an array. + * + * * Function: Provided a token and the stream, returns an expected next step. + * * Array: A list of steps to take in order. + * + * A step is either another rule, or a terminal description of a token. If it + * is a rule, that rule is pushed onto the stack and the parsing continues from + * that point. + * + * If it is a terminal description, the token is checked against it using a + * `match` function. If the match is successful, the token is colored and the + * rule is stepped forward. If the match is unsuccessful, the remainder of the + * rule is skipped and the previous rule is advanced. + * + * This parsing algorithm allows for incremental online parsing within various + * levels of the syntax tree and results in a structured `state` linked-list + * which contains the relevant information to produce valuable typeaheads. + */ +const graphqlModeFactory = config => { + const parser = onlineParser({ + eatWhitespace: stream => stream.eatWhile(isIgnored), + lexRules: LexRules, + parseRules: ParseRules, + editorConfig: { tabSize: config.tabSize }, + }); + + return { + config, + startState: parser.startState, + token: parser.token, + indent, + electricInput: /^\s*[})\]]/, + fold: 'brace', + lineComment: '#', + closeBrackets: { + pairs: '()[]{}""', + explode: '()[]{}', + }, + }; +}; + +export default graphqlModeFactory; diff --git a/src/utils/modes/graphql/mode-indent.js b/src/utils/modes/graphql/mode-indent.js new file mode 100644 index 0000000..490dbd6 --- /dev/null +++ b/src/utils/modes/graphql/mode-indent.js @@ -0,0 +1,22 @@ +// oxlint-disable no-this-in-exported-function + +/** + * Vendored from `codemirror-graphql`'s `esm/utils/mode-indent.js`, stripped of its TypeScript + * types. + * + * @license MIT + * @see {@link https://github.com/graphql/graphiql/blob/main/packages/codemirror-graphql/src/utils/mode-indent.ts} + */ + +// Note: `electricInput` on a CodeMirror mode is written as `electricinput` in some type +// definitions, but CodeMirror itself looks it up as `electricInput`. +export default function indent(state, textAfter) { + const levels = state.levels; + // If there is no stack of levels, use the current level. + // Otherwise, use the top level, pre-emptively dedenting for close braces. + const level = + !levels || levels.length === 0 + ? state.indentLevel + : levels[levels.length - 1] - (this.electricInput?.test(textAfter) ? 1 : 0); + return (level || 0) * (this.config?.indentUnit || 0); +} diff --git a/src/utils/modes/graphql/mode.js b/src/utils/modes/graphql/mode.js new file mode 100644 index 0000000..ad5df22 --- /dev/null +++ b/src/utils/modes/graphql/mode.js @@ -0,0 +1,15 @@ +/** + * Vendored from `codemirror-graphql`'s `esm/mode.js`. Vendoring this mode (rather than depending + * on the `codemirror-graphql` package) lets us pull in only the ~600 lines this mode actually + * needs from `graphql-language-service`'s parser, instead of the full `graphql-language-service` + * package and its `graphql` peer dependency (whose validation rules alone account for several + * hundred KB of the published bundle). + * + * @license MIT + * @see {@link https://github.com/graphql/graphiql/blob/main/packages/codemirror-graphql/src/mode.ts} + */ +import CodeMirror from 'codemirror'; + +import modeFactory from './mode-factory.js'; + +CodeMirror.defineMode('graphql', modeFactory); diff --git a/src/utils/modes/graphql/onlineParser.js b/src/utils/modes/graphql/onlineParser.js new file mode 100644 index 0000000..ca8cc81 --- /dev/null +++ b/src/utils/modes/graphql/onlineParser.js @@ -0,0 +1,309 @@ +// oxlint-disable no-param-reassign +// oxlint-disable no-continue +// oxlint-disable no-plusplus +// oxlint-disable prefer-template +// oxlint-disable unicorn/no-instanceof-array +// oxlint-disable unicorn/no-instanceof-builtins +// oxlint-disable unicorn/no-useless-undefined + +/** + * Vendored from `graphql-language-service`'s `esm/parser/onlineParser.js`, stripped of its + * TypeScript types. The only thing this file needed from the `graphql` package was the + * `Kind.DOCUMENT` string constant (`'Document'`), which is inlined below instead of pulling in + * all of `graphql` (and its validation rules) just for a mode. + * + * @license MIT + * @see {@link https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service/src/parser/onlineParser.ts} + */ + +/** + * Builds an online immutable parser, designed to be used as part of a syntax + * highlighting and code intelligence tools. + * + * Options: + * + * eatWhitespace: ( + * stream: Stream | CodeMirror.StringStream | CharacterStream + * ) => boolean + * Use CodeMirror API. + * + * LexRules: { [name: string]: RegExp }, Includes `Punctuation`, `Comment`. + * + * ParseRules: { [name: string]: Array }, Includes `Document`. + * + * editorConfig: { [name: string]: any }, Provides an editor-specific + * configurations set. + * + */ +import { LexRules, ParseRules, isIgnored } from './Rules.js'; + +export default function onlineParser( + options = { + eatWhitespace: stream => stream.eatWhile(isIgnored), + lexRules: LexRules, + parseRules: ParseRules, + editorConfig: {}, + }, +) { + return { + startState() { + const initialState = { + level: 0, + step: 0, + name: null, + kind: null, + type: null, + rule: null, + needsSeparator: false, + prevState: null, + }; + + pushRule(options.parseRules, initialState, 'Document'); + return initialState; + }, + token(stream, state) { + return getToken(stream, state, options); + }, + }; +} + +function getToken(stream, state, options) { + if (state.inBlockstring) { + if (stream.match(/.*"""/)) { + state.inBlockstring = false; + return 'string'; + } + stream.skipToEnd(); + return 'string'; + } + + const { lexRules, parseRules, eatWhitespace, editorConfig } = options; + // Restore state after an empty-rule. + if (state.rule && state.rule.length === 0) { + popRule(state); + } else if (state.needsAdvance) { + state.needsAdvance = false; + advanceRule(state, true); + } + + // Remember initial indentation + if (stream.sol()) { + const tabSize = editorConfig?.tabSize || 2; + state.indentLevel = Math.floor(stream.indentation() / tabSize); + } + + // Consume spaces and ignored characters + if (eatWhitespace(stream)) { + return 'ws'; + } + + // Get a matched token from the stream, using lex + const token = lex(lexRules, stream); + + // If there's no matching token, skip ahead. + if (!token) { + const matchedSomething = stream.match(/\S+/); + if (!matchedSomething) { + // We need to eat at least one character, and we couldn't match any + // non-whitespace, so it must be exotic whitespace. + stream.match(/\s/); + } + pushRule(SpecialParseRules, state, 'Invalid'); + return 'invalidchar'; + } + + // If the next token is a Comment, insert a Comment parsing rule. + if (token.kind === 'Comment') { + pushRule(SpecialParseRules, state, 'Comment'); + return 'comment'; + } + + // Save state before continuing. + const backupState = assign({}, state); + + // Handle changes in expected indentation level + if (token.kind === 'Punctuation') { + if (/^[{([]/.test(token.value)) { + if (state.indentLevel !== undefined) { + // Push on the stack of levels one level deeper than the current level. + state.levels = (state.levels || []).concat(state.indentLevel + 1); + } + } else if (/^[})\]]/.test(token.value)) { + // Pop from the stack of levels. + // If the top of the stack is lower than the current level, lower the + // current level to match. + const levels = (state.levels = (state.levels || []).slice(0, -1)); + if (state.indentLevel && levels.length > 0 && levels.at(-1) < state.indentLevel) { + state.indentLevel = levels.at(-1); + } + } + } + + while (state.rule) { + // If this is a forking rule, determine what rule to use based on + // the current token, otherwise expect based on the current step. + let expected = + typeof state.rule === 'function' ? (state.step === 0 ? state.rule(token, stream) : null) : state.rule[state.step]; + + // Separator between list elements if necessary. + if (state.needsSeparator) { + expected = expected?.separator; + } + + if (expected) { + // Un-wrap optional/list parseRules. + if (expected.ofRule) { + expected = expected.ofRule; + } + + // A string represents a Rule + if (typeof expected === 'string') { + pushRule(parseRules, state, expected); + continue; + } + + // Otherwise, match a Terminal. + if (expected.match?.(token)) { + if (expected.update) { + expected.update(state, token); + } + + // If this token was a punctuator, advance the parse rule, otherwise + // mark the state to be advanced before the next token. This ensures + // that tokens which can be appended to keep the appropriate state. + if (token.kind === 'Punctuation') { + advanceRule(state, true); + } else { + state.needsAdvance = true; + } + + return expected.style; + } + } + unsuccessful(state); + } + + // The parser does not know how to interpret this token, do not affect state. + assign(state, backupState); + pushRule(SpecialParseRules, state, 'Invalid'); + return 'invalidchar'; +} + +// Utility function to assign from object to another object. +function assign(to, from) { + const keys = Object.keys(from); + for (let i = 0; i < keys.length; i++) { + to[keys[i]] = from[keys[i]]; + } + return to; +} + +// A special rule set for parsing comment tokens. +const SpecialParseRules = { + Invalid: [], + Comment: [], +}; + +// Push a new rule onto the state. +function pushRule(rules, state, ruleKind) { + if (!rules[ruleKind]) { + throw new TypeError('Unknown rule: ' + ruleKind); + } + state.prevState = { ...state }; + state.kind = ruleKind; + state.name = null; + state.type = null; + state.rule = rules[ruleKind]; + state.step = 0; + state.needsSeparator = false; +} + +// Pop the current rule from the state. +function popRule(state) { + // Check if there's anything to pop + if (!state.prevState) { + return; + } + state.kind = state.prevState.kind; + state.name = state.prevState.name; + state.type = state.prevState.type; + state.rule = state.prevState.rule; + state.step = state.prevState.step; + state.needsSeparator = state.prevState.needsSeparator; + state.prevState = state.prevState.prevState; +} + +// Advance the step of the current rule. +function advanceRule(state, successful) { + // If this is advancing successfully and the current state is a list, give + // it an opportunity to repeat itself. + if (isList(state) && state.rule) { + const step = state.rule[state.step]; + if (step.separator) { + const { separator } = step; + state.needsSeparator = !state.needsSeparator; + // If the separator was optional, then give it an opportunity to repeat. + if (!state.needsSeparator && separator.ofRule) { + return; + } + } + // If this was a successful list parse, then allow it to repeat itself. + if (successful) { + return; + } + } + + // Advance the step in the rule. If the rule is completed, pop + // the rule and advance the parent rule as well (recursively). + state.needsSeparator = false; + state.step++; + + // While the current rule is completed. + while (state.rule && !(Array.isArray(state.rule) && state.step < state.rule.length)) { + popRule(state); + + if (state.rule) { + // Do not advance a List step so it has the opportunity to repeat itself. + if (isList(state)) { + if (state.rule?.[state.step].separator) { + state.needsSeparator = !state.needsSeparator; + } + } else { + state.needsSeparator = false; + state.step++; + } + } + } +} + +function isList(state) { + const step = Array.isArray(state.rule) && typeof state.rule[state.step] !== 'string' && state.rule[state.step]; + return step && step.isList; +} + +// Unwind the state after an unsuccessful match. +function unsuccessful(state) { + // Fall back to the parent rule until you get to an optional or list rule or + // until the entire stack of rules is empty. + while (state.rule && !(Array.isArray(state.rule) && state.rule[state.step].ofRule)) { + popRule(state); + } + + // If there is still a rule, it must be an optional or list rule. + // Consider this rule a success so that we may move past it. + if (state.rule) { + advanceRule(state, false); + } +} + +// Given a stream, returns a { kind, value } pair, or null. +function lex(lexRules, stream) { + const kinds = Object.keys(lexRules); + for (let i = 0; i < kinds.length; i++) { + const match = stream.match(lexRules[kinds[i]]); + if (match && match instanceof Array) { + return { kind: kinds[i], value: match[0] }; + } + } + return undefined; +} diff --git a/webpack.config.js b/webpack.config.js index 245c44d..77ada8a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -31,25 +31,42 @@ const base = { ], }, optimization: { - minimize: false, + minimize: true, }, - externals: { - react: { - root: 'React', - commonjs2: 'react', - commonjs: 'react', - amd: 'react', - umd: 'react', + externals: [ + { + react: { + root: 'React', + commonjs2: 'react', + commonjs: 'react', + amd: 'react', + umd: 'react', + }, + 'react-dom': { + root: 'ReactDOM', + commonjs2: 'react-dom', + commonjs: 'react-dom', + amd: 'react-dom', + umd: 'react-dom', + }, + '@readme/variable': '@readme/variable', + codemirror: 'codemirror', + 'react-codemirror2': 'react-codemirror2', + 'prop-types': 'prop-types', }, - 'react-dom': { - root: 'ReactDOM', - commonjs2: 'react-dom', - commonjs: 'react-dom', - amd: 'react-dom', - umd: 'react-dom', + // `codemirror`, `react-codemirror2`, and `prop-types` are runtime `dependencies` that every + // consumer already installs, so bundling them too is double-shipping. This externalizes deep + // requires as well (mode/addon files, `codemirror/lib/codemirror`), so the consumer's own + // bundler resolves them from its own `node_modules`. CSS imports (e.g. + // `codemirror/addon/scroll/simplescrollbars.css`) stay bundled via style-loader so consumers + // don't need their own CSS handling for our node_modules imports. + ({ request }, callback) => { + if (request.startsWith('codemirror/') && !request.endsWith('.css')) { + return callback(null, `commonjs2 ${request}`); + } + callback(); }, - '@readme/variable': '@readme/variable', - }, + ], resolve: { extensions: ['.js', '.jsx', '.ts', '.tsx'], },