mirror of
https://github.com/freeCodeCamp/freeCodeCamp.git
synced 2026-05-11 18:02:40 -04:00
refactor: update gatsby schema, remove customization + use inferrable type (#65857)
This commit is contained in:
committed by
GitHub
parent
3250669a2b
commit
e902fd270f
@@ -163,3 +163,15 @@ exports.onCreateBabelConfig = ({ actions }) => {
|
||||
name: '@babel/plugin-proposal-export-default-from'
|
||||
});
|
||||
};
|
||||
|
||||
exports.createSchemaCustomization = ({ actions }) => {
|
||||
const { createTypes } = actions;
|
||||
// This hook is supported by the test runner, but is not currently used by the
|
||||
// client, so we have to tell Gatsby that it exists.
|
||||
const typeDefs = `
|
||||
type ChallengeNodeChallengeHooks {
|
||||
afterEach: String
|
||||
}
|
||||
`;
|
||||
createTypes(typeDefs);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,173 @@
|
||||
### Type definitions saved at 2026-01-20T15:53:20.990Z ###
|
||||
### Type definitions saved at 2026-02-12T12:28:08.262Z ###
|
||||
|
||||
enum RemoteFileFit {
|
||||
COVER
|
||||
FILL
|
||||
OUTSIDE
|
||||
CONTAIN
|
||||
}
|
||||
|
||||
enum RemoteFileFormat {
|
||||
AUTO
|
||||
JPG
|
||||
PNG
|
||||
WEBP
|
||||
AVIF
|
||||
}
|
||||
|
||||
enum RemoteFileLayout {
|
||||
FIXED
|
||||
FULL_WIDTH
|
||||
CONSTRAINED
|
||||
}
|
||||
|
||||
enum RemoteFilePlaceholder {
|
||||
DOMINANT_COLOR
|
||||
BLURRED
|
||||
TRACED_SVG
|
||||
NONE
|
||||
}
|
||||
|
||||
enum RemoteFileCropFocus {
|
||||
CENTER
|
||||
TOP
|
||||
RIGHT
|
||||
BOTTOM
|
||||
LEFT
|
||||
ENTROPY
|
||||
EDGES
|
||||
FACES
|
||||
}
|
||||
|
||||
type RemoteFileResize {
|
||||
width: Int
|
||||
height: Int
|
||||
src: String
|
||||
}
|
||||
|
||||
"""
|
||||
Remote Interface
|
||||
"""
|
||||
interface RemoteFile {
|
||||
id: ID!
|
||||
mimeType: String!
|
||||
filename: String!
|
||||
filesize: Int
|
||||
width: Int
|
||||
height: Int
|
||||
publicUrl: String!
|
||||
resize(
|
||||
width: Int
|
||||
height: Int
|
||||
aspectRatio: Float
|
||||
fit: RemoteFileFit = COVER
|
||||
|
||||
"""
|
||||
The image formats to generate. Valid values are AUTO (meaning the same
|
||||
format as the source image), JPG, PNG, WEBP and AVIF.
|
||||
The default value is [AUTO, WEBP, AVIF], and you should rarely need to
|
||||
change this. Take care if you specify JPG or PNG when you do
|
||||
not know the formats of the source images, as this could lead to unwanted
|
||||
results such as converting JPEGs to PNGs. Specifying
|
||||
both PNG and JPG is not supported and will be ignored.
|
||||
"""
|
||||
format: RemoteFileFormat = AUTO
|
||||
cropFocus: [RemoteFileCropFocus]
|
||||
quality: Int = 75
|
||||
): RemoteFileResize
|
||||
|
||||
"""
|
||||
Data used in the <GatsbyImage /> component. See https://gatsby.dev/img for more info.
|
||||
"""
|
||||
gatsbyImage(
|
||||
"""
|
||||
The layout for the image.
|
||||
FIXED: A static image sized, that does not resize according to the screen width
|
||||
FULL_WIDTH: The image resizes to fit its container. Pass a "sizes" option if
|
||||
it isn't going to be the full width of the screen.
|
||||
CONSTRAINED: Resizes to fit its container, up to a maximum width, at which point it will remain fixed in size.
|
||||
"""
|
||||
layout: RemoteFileLayout = CONSTRAINED
|
||||
|
||||
"""
|
||||
The display width of the generated image for layout = FIXED, and the display
|
||||
width of the largest image for layout = CONSTRAINED.
|
||||
The actual largest image resolution will be this value multiplied by the largest value in outputPixelDensities
|
||||
Ignored if layout = FLUID.
|
||||
"""
|
||||
width: Int
|
||||
|
||||
"""
|
||||
If set, the height of the generated image. If omitted, it is calculated from
|
||||
the supplied width, matching the aspect ratio of the source image.
|
||||
"""
|
||||
height: Int
|
||||
|
||||
"""
|
||||
Format of generated placeholder image, displayed while the main image loads.
|
||||
BLURRED: a blurred, low resolution image, encoded as a base64 data URI
|
||||
DOMINANT_COLOR: a solid color, calculated from the dominant color of the image (default).
|
||||
TRACED_SVG: deprecated. Will use DOMINANT_COLOR.
|
||||
NONE: no placeholder. Set the argument "backgroundColor" to use a fixed background color.
|
||||
"""
|
||||
placeholder: RemoteFilePlaceholder = DOMINANT_COLOR
|
||||
|
||||
"""
|
||||
If set along with width or height, this will set the value of the other
|
||||
dimension to match the provided aspect ratio, cropping the image if needed.
|
||||
If neither width or height is provided, height will be set based on the intrinsic width of the source image.
|
||||
"""
|
||||
aspectRatio: Float
|
||||
|
||||
"""
|
||||
The image formats to generate. Valid values are AUTO (meaning the same
|
||||
format as the source image), JPG, PNG, WEBP and AVIF.
|
||||
The default value is [AUTO, WEBP, AVIF], and you should rarely need to
|
||||
change this. Take care if you specify JPG or PNG when you do
|
||||
not know the formats of the source images, as this could lead to unwanted
|
||||
results such as converting JPEGs to PNGs. Specifying
|
||||
both PNG and JPG is not supported and will be ignored.
|
||||
"""
|
||||
formats: [RemoteFileFormat!] = [AUTO, WEBP, AVIF]
|
||||
|
||||
"""
|
||||
A list of image pixel densities to generate for FIXED and CONSTRAINED
|
||||
images. You should rarely need to change this. It will never generate images
|
||||
larger than the source, and will always include a 1x image.
|
||||
Default is [ 1, 2 ] for fixed images, meaning 1x, 2x, and [0.25, 0.5, 1, 2]
|
||||
for fluid. In this case, an image with a fluid layout and width = 400 would
|
||||
generate images at 100, 200, 400 and 800px wide.
|
||||
"""
|
||||
outputPixelDensities: [Float] = [0.25, 0.5, 1, 2]
|
||||
|
||||
"""
|
||||
Specifies the image widths to generate. You should rarely need to change
|
||||
this. For FIXED and CONSTRAINED images it is better to allow these to be
|
||||
determined automatically,
|
||||
based on the image size. For FULL_WIDTH images this can be used to override
|
||||
the default, which is [750, 1080, 1366, 1920].
|
||||
It will never generate any images larger than the source.
|
||||
"""
|
||||
breakpoints: [Int] = [750, 1080, 1366, 1920]
|
||||
|
||||
"""
|
||||
The "sizes" property, passed to the img tag. This describes the display size of the image.
|
||||
This does not affect the generated images, but is used by the browser to
|
||||
decide which images to download. You can leave this blank for fixed images,
|
||||
or if the responsive image
|
||||
container will be the full width of the screen. In these cases we will generate an appropriate value.
|
||||
"""
|
||||
sizes: String
|
||||
|
||||
"""
|
||||
Background color applied to the wrapper, or when "letterboxing" an image to another aspect ratio.
|
||||
"""
|
||||
backgroundColor: String
|
||||
fit: RemoteFileFit = COVER
|
||||
cropFocus: [RemoteFileCropFocus]
|
||||
quality: Int = 75
|
||||
): GatsbyImageData
|
||||
}
|
||||
|
||||
type File implements Node @dontInfer {
|
||||
sourceInstanceName: String!
|
||||
@@ -68,18 +237,19 @@ type Directory implements Node @dontInfer {
|
||||
ctime: Date! @dateformat
|
||||
birthtime: Date @deprecated(reason: "Use `birthTime` instead")
|
||||
birthtimeMs: Float @deprecated(reason: "Use `birthTime` instead")
|
||||
blksize: Int
|
||||
blocks: Int
|
||||
}
|
||||
|
||||
type Site implements Node @dontInfer {
|
||||
type Site implements Node @derivedTypes @dontInfer {
|
||||
buildTime: Date @dateformat
|
||||
siteMetadata: SiteSiteMetadata
|
||||
port: Int
|
||||
host: String
|
||||
flags: SiteFlags
|
||||
trailingSlash: String
|
||||
pathPrefix: String
|
||||
polyfill: Boolean
|
||||
jsxRuntime: String
|
||||
graphqlTypegen: Boolean
|
||||
}
|
||||
|
||||
type SiteSiteMetadata {
|
||||
@@ -108,6 +278,8 @@ type SitePage implements Node @dontInfer {
|
||||
internalComponentName: String!
|
||||
componentChunkName: String!
|
||||
matchPath: String
|
||||
pageContext: JSON @proxy(from: "context", fromNode: false)
|
||||
pluginCreator: SitePlugin @link(by: "id", from: "pluginCreatorId")
|
||||
}
|
||||
|
||||
type SitePlugin implements Node @dontInfer {
|
||||
@@ -118,55 +290,8 @@ type SitePlugin implements Node @dontInfer {
|
||||
browserAPIs: [String]
|
||||
ssrAPIs: [String]
|
||||
pluginFilepath: String
|
||||
pluginOptions: SitePluginPluginOptions
|
||||
packageJson: SitePluginPackageJson
|
||||
}
|
||||
|
||||
type SitePluginPluginOptions {
|
||||
analyzerMode: String
|
||||
postcssOptions: SitePluginPluginOptionsPostcssOptions
|
||||
prefixes: [String]
|
||||
name: String
|
||||
curriculumPath: String
|
||||
path: String
|
||||
jsFrontmatterEngine: Boolean
|
||||
identity: String
|
||||
pathCheck: Boolean
|
||||
allExtensions: Boolean
|
||||
isTSX: Boolean
|
||||
jsxPragma: String
|
||||
}
|
||||
|
||||
type SitePluginPluginOptionsPostcssOptions {
|
||||
config: String
|
||||
}
|
||||
|
||||
type SitePluginPackageJson {
|
||||
name: String
|
||||
description: String
|
||||
version: String
|
||||
main: String
|
||||
author: String
|
||||
license: String
|
||||
dependencies: [SitePluginPackageJsonDependencies]
|
||||
devDependencies: [SitePluginPackageJsonDevDependencies]
|
||||
peerDependencies: [SitePluginPackageJsonPeerDependencies]
|
||||
keywords: [String]
|
||||
}
|
||||
|
||||
type SitePluginPackageJsonDependencies {
|
||||
name: String
|
||||
version: String
|
||||
}
|
||||
|
||||
type SitePluginPackageJsonDevDependencies {
|
||||
name: String
|
||||
version: String
|
||||
}
|
||||
|
||||
type SitePluginPackageJsonPeerDependencies {
|
||||
name: String
|
||||
version: String
|
||||
pluginOptions: JSON
|
||||
packageJson: JSON
|
||||
}
|
||||
|
||||
type SiteBuildMetadata implements Node @dontInfer {
|
||||
@@ -202,6 +327,7 @@ type MarkdownWordCount {
|
||||
|
||||
type MarkdownRemark implements Node
|
||||
@childOf(mimeTypes: ["text/markdown", "text/x-markdown"], types: ["File"])
|
||||
@derivedTypes
|
||||
@dontInfer {
|
||||
frontmatter: MarkdownRemarkFrontmatter
|
||||
excerpt: String
|
||||
@@ -214,244 +340,26 @@ type MarkdownRemarkFrontmatter {
|
||||
title: String
|
||||
superBlock: String
|
||||
certification: String
|
||||
block: String
|
||||
}
|
||||
|
||||
type MarkdownRemarkFields {
|
||||
nodeIdentity: String
|
||||
slug: String
|
||||
}
|
||||
|
||||
type ChallengeNode implements Node @dontInfer {
|
||||
challenge: Challenge
|
||||
sourceInstanceName: String
|
||||
}
|
||||
|
||||
type Challenge {
|
||||
assignments: [String]
|
||||
bilibiliIds: BilibiliIds
|
||||
block: String
|
||||
blockId: String
|
||||
blockLayout: String
|
||||
blockLabel: String
|
||||
certification: String
|
||||
challengeFiles: [FileContents]
|
||||
challengeOrder: Int
|
||||
challengeType: Int
|
||||
chapter: String
|
||||
dashedName: String
|
||||
demoType: String
|
||||
description: String
|
||||
disableLoopProtectPreview: Boolean
|
||||
disableLoopProtectTests: Boolean
|
||||
explanation: String
|
||||
fillInTheBlank: FillInTheBlank
|
||||
forumTopicId: Int
|
||||
hasEditableBoundaries: Boolean
|
||||
helpCategory: String
|
||||
hooks: Hooks
|
||||
id: String
|
||||
instructions: String
|
||||
isLastChallengeInBlock: Boolean
|
||||
isPrivate: Boolean
|
||||
lang: String
|
||||
module: String
|
||||
msTrophyId: String
|
||||
nodules: [Nodule]
|
||||
notes: String
|
||||
order: Int
|
||||
prerequisites: [PrerequisiteChallenge]
|
||||
questions: [Question]
|
||||
quizzes: [Quiz]
|
||||
required: [RequiredResource]
|
||||
saveSubmissionToDB: Boolean
|
||||
scene: Scene
|
||||
solutions: [[FileContents]]
|
||||
suborder: Int
|
||||
superBlock: String
|
||||
superOrder: Int
|
||||
template: String
|
||||
tests: [Test]
|
||||
fields: ChallengeFields
|
||||
title: String
|
||||
transcript: String
|
||||
translationPending: Boolean
|
||||
url: String
|
||||
usesMultifileEditor: Boolean
|
||||
videoId: String
|
||||
videoLocaleIds: VideoLocaleIds
|
||||
videoUrl: String
|
||||
isExam: Boolean
|
||||
showSpeakingButton: Boolean
|
||||
inputType: String
|
||||
}
|
||||
|
||||
type BilibiliIds {
|
||||
aid: Int
|
||||
bvid: String
|
||||
cid: Int
|
||||
}
|
||||
|
||||
type FileContents {
|
||||
fileKey: String
|
||||
ext: String
|
||||
name: String
|
||||
contents: String
|
||||
head: String
|
||||
tail: String
|
||||
editableRegionBoundaries: [Int]
|
||||
path: String
|
||||
error: String
|
||||
seed: String
|
||||
id: String
|
||||
history: [String]
|
||||
}
|
||||
|
||||
type FillInTheBlank {
|
||||
sentence: String
|
||||
blanks: [Blank]
|
||||
inputType: String
|
||||
}
|
||||
|
||||
type Blank {
|
||||
answer: String
|
||||
feedback: String
|
||||
}
|
||||
|
||||
type Hooks {
|
||||
type ChallengeNodeChallengeHooks {
|
||||
afterEach: String
|
||||
beforeAll: String
|
||||
beforeEach: String
|
||||
afterAll: String
|
||||
afterEach: String
|
||||
}
|
||||
|
||||
type Nodule {
|
||||
type: String
|
||||
data: JSON
|
||||
}
|
||||
|
||||
type PrerequisiteChallenge {
|
||||
id: String
|
||||
title: String
|
||||
}
|
||||
|
||||
type Question {
|
||||
text: String
|
||||
answers: [Answer]
|
||||
solution: Int
|
||||
}
|
||||
|
||||
type Answer {
|
||||
answer: String
|
||||
feedback: String
|
||||
audioId: String
|
||||
}
|
||||
|
||||
type Quiz {
|
||||
questions: [QuizQuestion]
|
||||
}
|
||||
|
||||
type QuizAudio {
|
||||
filename: String
|
||||
startTimestamp: Float
|
||||
finishTimestamp: Float
|
||||
}
|
||||
|
||||
type QuizTranscriptLine {
|
||||
character: String
|
||||
text: String
|
||||
}
|
||||
|
||||
type QuizAudioData {
|
||||
audio: QuizAudio
|
||||
transcript: [QuizTranscriptLine]
|
||||
}
|
||||
|
||||
type QuizQuestion {
|
||||
text: String
|
||||
distractors: [String]
|
||||
answer: String
|
||||
audioData: QuizAudioData
|
||||
}
|
||||
|
||||
type RequiredResource {
|
||||
link: String
|
||||
raw: Boolean
|
||||
src: String
|
||||
crossDomain: Boolean
|
||||
}
|
||||
|
||||
type Scene {
|
||||
setup: SceneSetup
|
||||
commands: [SceneCommands]
|
||||
}
|
||||
|
||||
type SceneSetup {
|
||||
background: String
|
||||
characters: [SetupCharacter]
|
||||
audio: SetupAudio
|
||||
alwaysShowDialogue: Boolean
|
||||
}
|
||||
|
||||
type SetupCharacter {
|
||||
character: String
|
||||
position: CharacterPosition
|
||||
opacity: Float
|
||||
}
|
||||
|
||||
type CharacterPosition {
|
||||
x: Float
|
||||
y: Float
|
||||
z: Float
|
||||
}
|
||||
|
||||
type SetupAudio {
|
||||
filename: String
|
||||
startTime: Float
|
||||
startTimestamp: Float
|
||||
finishTimestamp: Float
|
||||
}
|
||||
|
||||
type SceneCommands {
|
||||
background: String
|
||||
character: String
|
||||
position: CharacterPosition
|
||||
opacity: Float
|
||||
startTime: Float
|
||||
finishTime: Float
|
||||
dialogue: Dialogue
|
||||
}
|
||||
|
||||
type Dialogue {
|
||||
text: String
|
||||
align: String
|
||||
}
|
||||
|
||||
type Test {
|
||||
id: String
|
||||
text: String
|
||||
testString: String
|
||||
title: String
|
||||
}
|
||||
|
||||
type ChallengeFields {
|
||||
slug: String
|
||||
blockHashSlug: String
|
||||
}
|
||||
|
||||
type VideoLocaleIds {
|
||||
espanol: String
|
||||
italian: String
|
||||
portuguese: String
|
||||
}
|
||||
|
||||
type SuperBlockStructure implements Node @dontInfer {
|
||||
blocks: [String]
|
||||
superBlock: String
|
||||
type SuperBlockStructure implements Node @derivedTypes @dontInfer {
|
||||
chapters: [SuperBlockStructureChapters]
|
||||
superBlock: String
|
||||
blocks: [String]
|
||||
}
|
||||
|
||||
type SuperBlockStructureChapters {
|
||||
type SuperBlockStructureChapters @derivedTypes {
|
||||
dashedName: String
|
||||
modules: [SuperBlockStructureChaptersModules]
|
||||
chapterType: String
|
||||
@@ -465,12 +373,249 @@ type SuperBlockStructureChaptersModules {
|
||||
comingSoon: Boolean
|
||||
}
|
||||
|
||||
type CertificateNode implements Node @dontInfer {
|
||||
type ChallengeNode implements Node @derivedTypes @dontInfer {
|
||||
sourceInstanceName: String
|
||||
challenge: ChallengeNodeChallenge
|
||||
}
|
||||
|
||||
type ChallengeNodeChallenge @derivedTypes {
|
||||
id: String
|
||||
title: String
|
||||
challengeType: Int
|
||||
dashedName: String
|
||||
demoType: String
|
||||
challengeFiles: [ChallengeNodeChallengeChallengeFiles]
|
||||
solutions: [[ChallengeNodeChallengeSolutions]]
|
||||
assignments: [String]
|
||||
tests: [ChallengeNodeChallengeTests]
|
||||
description: String
|
||||
translationPending: Boolean
|
||||
sourceLocation: String
|
||||
block: String
|
||||
blockLabel: String
|
||||
blockLayout: String
|
||||
hasEditableBoundaries: Boolean
|
||||
order: Int
|
||||
instructions: String
|
||||
questions: [ChallengeNodeChallengeQuestions]
|
||||
superBlock: String
|
||||
superOrder: Int
|
||||
challengeOrder: Int
|
||||
isLastChallengeInBlock: Boolean
|
||||
required: [ChallengeNodeChallengeRequired]
|
||||
helpCategory: String
|
||||
usesMultifileEditor: Boolean
|
||||
disableLoopProtectTests: Boolean
|
||||
disableLoopProtectPreview: Boolean
|
||||
certification: String
|
||||
fields: ChallengeNodeChallengeFields
|
||||
quizzes: [ChallengeNodeChallengeQuizzes]
|
||||
chapter: String
|
||||
module: String
|
||||
hooks: ChallengeNodeChallengeHooks
|
||||
nodules: [ChallengeNodeChallengeNodules]
|
||||
forumTopicId: Int
|
||||
videoId: String
|
||||
bilibiliIds: ChallengeNodeChallengeBilibiliIds
|
||||
saveSubmissionToDB: Boolean
|
||||
lang: String
|
||||
scene: ChallengeNodeChallengeScene
|
||||
explanation: String
|
||||
fillInTheBlank: ChallengeNodeChallengeFillInTheBlank
|
||||
inputType: String
|
||||
videoUrl: String
|
||||
url: String
|
||||
template: String
|
||||
transcript: String
|
||||
isExam: Boolean
|
||||
showSpeakingButton: Boolean
|
||||
videoLocaleIds: ChallengeNodeChallengeVideoLocaleIds
|
||||
notes: String
|
||||
prerequisites: [ChallengeNodeChallengePrerequisites]
|
||||
msTrophyId: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeChallengeFiles {
|
||||
head: String
|
||||
tail: String
|
||||
id: String
|
||||
editableRegionBoundaries: [Int]
|
||||
history: [String]
|
||||
name: String
|
||||
ext: String
|
||||
path: String
|
||||
fileKey: String
|
||||
contents: String
|
||||
seed: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSolutions {
|
||||
head: String
|
||||
tail: String
|
||||
id: String
|
||||
history: [String]
|
||||
name: String
|
||||
ext: String
|
||||
path: String
|
||||
fileKey: String
|
||||
contents: String
|
||||
seed: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeTests {
|
||||
text: String
|
||||
testString: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuestions @derivedTypes {
|
||||
text: String
|
||||
answers: [ChallengeNodeChallengeQuestionsAnswers]
|
||||
solution: Int
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuestionsAnswers {
|
||||
answer: String
|
||||
feedback: String
|
||||
audioId: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeRequired {
|
||||
src: String
|
||||
link: String
|
||||
raw: Boolean
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeFields {
|
||||
slug: String
|
||||
blockHashSlug: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuizzes @derivedTypes {
|
||||
questions: [ChallengeNodeChallengeQuizzesQuestions]
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuizzesQuestions @derivedTypes {
|
||||
text: String
|
||||
distractors: [String]
|
||||
answer: String
|
||||
audioData: ChallengeNodeChallengeQuizzesQuestionsAudioData
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuizzesQuestionsAudioData @derivedTypes {
|
||||
audio: ChallengeNodeChallengeQuizzesQuestionsAudioDataAudio
|
||||
transcript: [ChallengeNodeChallengeQuizzesQuestionsAudioDataTranscript]
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuizzesQuestionsAudioDataAudio {
|
||||
filename: String
|
||||
startTimestamp: Int
|
||||
finishTimestamp: Float
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeQuizzesQuestionsAudioDataTranscript {
|
||||
character: String
|
||||
text: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeNodules @derivedTypes {
|
||||
type: String
|
||||
contents: String
|
||||
files: [ChallengeNodeChallengeNodulesFiles]
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeNodulesFiles {
|
||||
contents: String
|
||||
ext: String
|
||||
name: String
|
||||
contentsHtml: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeBilibiliIds {
|
||||
aid: Int
|
||||
bvid: String
|
||||
cid: Int
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeScene @derivedTypes {
|
||||
setup: ChallengeNodeChallengeSceneSetup
|
||||
commands: [ChallengeNodeChallengeSceneCommands]
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneSetup @derivedTypes {
|
||||
background: String
|
||||
characters: [ChallengeNodeChallengeSceneSetupCharacters]
|
||||
audio: ChallengeNodeChallengeSceneSetupAudio
|
||||
alwaysShowDialogue: Boolean
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneSetupCharacters @derivedTypes {
|
||||
character: String
|
||||
position: ChallengeNodeChallengeSceneSetupCharactersPosition
|
||||
opacity: Int
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneSetupCharactersPosition {
|
||||
x: Int
|
||||
y: Int
|
||||
z: Float
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneSetupAudio {
|
||||
filename: String
|
||||
startTime: Float
|
||||
startTimestamp: Float
|
||||
finishTimestamp: Float
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneCommands @derivedTypes {
|
||||
character: String
|
||||
opacity: Int
|
||||
startTime: Float
|
||||
finishTime: Float
|
||||
dialogue: ChallengeNodeChallengeSceneCommandsDialogue
|
||||
position: ChallengeNodeChallengeSceneCommandsPosition
|
||||
background: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneCommandsDialogue {
|
||||
text: String
|
||||
align: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeSceneCommandsPosition {
|
||||
x: Int
|
||||
y: Int
|
||||
z: Float
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeFillInTheBlank @derivedTypes {
|
||||
sentence: String
|
||||
blanks: [ChallengeNodeChallengeFillInTheBlankBlanks]
|
||||
inputType: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeFillInTheBlankBlanks {
|
||||
answer: String
|
||||
feedback: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengeVideoLocaleIds {
|
||||
espanol: String
|
||||
italian: String
|
||||
portuguese: String
|
||||
}
|
||||
|
||||
type ChallengeNodeChallengePrerequisites {
|
||||
id: String
|
||||
title: String
|
||||
}
|
||||
|
||||
type CertificateNode implements Node @derivedTypes @dontInfer {
|
||||
sourceInstanceName: String
|
||||
challenge: CertificateNodeChallenge
|
||||
}
|
||||
|
||||
type CertificateNodeChallenge {
|
||||
type CertificateNodeChallenge @derivedTypes {
|
||||
id: String
|
||||
title: String
|
||||
certification: String
|
||||
|
||||
@@ -193,12 +193,12 @@ type Nodule = ParagraphNodule | InteractiveEditorNodule;
|
||||
|
||||
type ParagraphNodule = {
|
||||
type: 'paragraph';
|
||||
data: string;
|
||||
contents: string;
|
||||
};
|
||||
|
||||
type InteractiveEditorNodule = {
|
||||
type: 'interactiveEditor';
|
||||
data: {
|
||||
files: {
|
||||
ext: Ext;
|
||||
name: string;
|
||||
contents: string;
|
||||
|
||||
@@ -102,8 +102,12 @@ const InteractiveEditor = ({ files }: Props) => {
|
||||
{layout === 'preview' ? (
|
||||
showConsole ? (
|
||||
<>
|
||||
<SandpackPreview style={{ flex: 1.5 }} />
|
||||
<SandpackPreview
|
||||
data-playwright-test-label='sp-preview'
|
||||
style={{ flex: 1.5 }}
|
||||
/>
|
||||
<SandpackConsole
|
||||
data-playwright-test-label='sp-console'
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'scroll'
|
||||
@@ -114,7 +118,10 @@ const InteractiveEditor = ({ files }: Props) => {
|
||||
<SandpackPreview />
|
||||
)
|
||||
) : (
|
||||
<SandpackConsole standalone={true} />
|
||||
<SandpackConsole
|
||||
data-playwright-test-label='sp-console'
|
||||
standalone={true}
|
||||
/>
|
||||
)}
|
||||
</SandpackStack>
|
||||
</SandpackLayout>
|
||||
|
||||
@@ -77,12 +77,12 @@ function renderNodule(
|
||||
) {
|
||||
switch (nodule.type) {
|
||||
case 'paragraph':
|
||||
return <PrismFormatted text={nodule.data} />;
|
||||
return <PrismFormatted text={nodule.contents} />;
|
||||
case 'interactiveEditor':
|
||||
if (showInteractiveEditor) {
|
||||
return <InteractiveEditor files={nodule.data} />;
|
||||
return <InteractiveEditor files={nodule.files} />;
|
||||
} else {
|
||||
const files = nodule.data;
|
||||
const { files } = nodule;
|
||||
return files.map((file, index) => (
|
||||
<PrismFormatted key={index} text={file.contentsHtml} />
|
||||
));
|
||||
@@ -390,7 +390,13 @@ export const query = graphql`
|
||||
description
|
||||
nodules {
|
||||
type
|
||||
data
|
||||
contents
|
||||
files {
|
||||
ext
|
||||
name
|
||||
contents
|
||||
contentsHtml
|
||||
}
|
||||
}
|
||||
explanation
|
||||
helpCategory
|
||||
|
||||
@@ -834,7 +834,44 @@ exports[`challenge schema > should not be changed without informing the mobile t
|
||||
"items": [
|
||||
{
|
||||
"keys": {
|
||||
"data": {
|
||||
"contents": {
|
||||
"type": "any",
|
||||
"whens": [
|
||||
{
|
||||
"is": {
|
||||
"allow": [
|
||||
{
|
||||
"override": true,
|
||||
},
|
||||
"paragraph",
|
||||
],
|
||||
"flags": {
|
||||
"only": true,
|
||||
"presence": "required",
|
||||
},
|
||||
"type": "any",
|
||||
},
|
||||
"otherwise": {
|
||||
"flags": {
|
||||
"presence": "forbidden",
|
||||
},
|
||||
"type": "any",
|
||||
},
|
||||
"ref": {
|
||||
"path": [
|
||||
"type",
|
||||
],
|
||||
},
|
||||
"then": {
|
||||
"flags": {
|
||||
"presence": "required",
|
||||
},
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
"files": {
|
||||
"type": "any",
|
||||
"whens": [
|
||||
{
|
||||
@@ -853,9 +890,9 @@ exports[`challenge schema > should not be changed without informing the mobile t
|
||||
},
|
||||
"otherwise": {
|
||||
"flags": {
|
||||
"presence": "required",
|
||||
"presence": "forbidden",
|
||||
},
|
||||
"type": "string",
|
||||
"type": "any",
|
||||
},
|
||||
"ref": {
|
||||
"path": [
|
||||
|
||||
@@ -205,7 +205,7 @@ export const schema = Joi.object().keys({
|
||||
nodules: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
type: Joi.valid('paragraph', 'interactiveEditor').required(),
|
||||
data: Joi.when('type', {
|
||||
files: Joi.when('type', {
|
||||
is: ['interactiveEditor'],
|
||||
then: Joi.array().items(
|
||||
Joi.object().keys({
|
||||
@@ -215,7 +215,12 @@ export const schema = Joi.object().keys({
|
||||
contentsHtml: Joi.string().required()
|
||||
})
|
||||
),
|
||||
otherwise: Joi.string().required()
|
||||
otherwise: Joi.forbidden()
|
||||
}),
|
||||
contents: Joi.when('type', {
|
||||
is: ['paragraph'],
|
||||
then: Joi.string().required(),
|
||||
otherwise: Joi.forbidden()
|
||||
})
|
||||
})
|
||||
),
|
||||
|
||||
@@ -1,77 +1,31 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
interface InteractiveFile {
|
||||
contents: string;
|
||||
ext: string;
|
||||
name: string;
|
||||
contentsHtml: string;
|
||||
}
|
||||
const html = {
|
||||
challengePath:
|
||||
'/learn/responsive-web-design-v9/lecture-what-is-css/what-are-some-default-browser-styles-applied-to-html',
|
||||
challengeTitle: 'What Are Some Default Browser Styles Applied to HTML?',
|
||||
paragraphOneText:
|
||||
'When you start working with HTML and CSS, you\'ll notice that some styles are applied to your web pages even before you write any CSS. These styles are called "default browser styles" or "user-agent styles".',
|
||||
codeSnippet: '<h1>Heading 1</h1>'
|
||||
};
|
||||
|
||||
interface Nodule {
|
||||
type: 'paragraph' | 'interactiveEditor';
|
||||
data: string | InteractiveFile[];
|
||||
}
|
||||
|
||||
interface PageData {
|
||||
result: {
|
||||
data: {
|
||||
challengeNode: {
|
||||
challenge: {
|
||||
title: string;
|
||||
nodules: Nodule[];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const challengePath =
|
||||
'/learn/responsive-web-design-v9/lecture-what-is-css/what-are-some-default-browser-styles-applied-to-html';
|
||||
|
||||
const challengeTitle = 'Test Challenge Title';
|
||||
const js = {
|
||||
challengePath:
|
||||
'/learn/javascript-v9/lecture-introduction-to-javascript/what-is-a-data-type'
|
||||
};
|
||||
|
||||
test.describe('Interactive Editor', () => {
|
||||
test('should render paragraph nodules as text and not show the interactive editor toggle', async ({
|
||||
// skipping because I don't know of a page with paragraph nodules that doesn't also have an interactive editor toggle
|
||||
test.skip('should render paragraph nodules as text and not show the interactive editor toggle', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.route(
|
||||
`**/page-data${challengePath}/page-data.json`,
|
||||
async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
const pageData = JSON.parse(body) as PageData;
|
||||
|
||||
pageData.result.data.challengeNode.challenge.title = challengeTitle;
|
||||
pageData.result.data.challengeNode.challenge.nodules = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: '<p>This is a plain text paragraph.</p>'
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: '<p>Another paragraph with <code>code</code> in it.</p>'
|
||||
}
|
||||
];
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(pageData)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(challengePath);
|
||||
await page.goto(html.challengePath);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: challengeTitle })
|
||||
page.getByRole('heading', { name: html.challengeTitle })
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText('This is a plain text paragraph.')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Another paragraph with code in it.')
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(html.paragraphOneText)).toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: /interactive editor/i })
|
||||
@@ -81,72 +35,18 @@ test.describe('Interactive Editor', () => {
|
||||
test('should toggle between interactive editor and static code view when Interactive Editor button is clicked', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.route(
|
||||
`**/page-data${challengePath}/page-data.json`,
|
||||
async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
const pageData = JSON.parse(body) as PageData;
|
||||
|
||||
pageData.result.data.challengeNode.challenge.title = challengeTitle;
|
||||
pageData.result.data.challengeNode.challenge.nodules = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: '<p>Introduction paragraph.</p>'
|
||||
},
|
||||
{
|
||||
type: 'interactiveEditor',
|
||||
data: [
|
||||
{
|
||||
contents: 'console.log("Toggle test");',
|
||||
ext: 'js',
|
||||
name: 'script-1',
|
||||
contentsHtml:
|
||||
'<pre><code class="language-javascript">console.log("Toggle test");</code></pre>'
|
||||
},
|
||||
{
|
||||
contents: '<div>HTML content</div>',
|
||||
ext: 'html',
|
||||
name: 'index-1',
|
||||
contentsHtml:
|
||||
'<pre><code class="language-html"><div>HTML content</div></code></pre>'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: '<p>Final paragraph.</p>'
|
||||
}
|
||||
];
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(pageData)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(challengePath);
|
||||
await page.goto(html.challengePath);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: challengeTitle })
|
||||
page.getByRole('heading', { name: html.challengeTitle })
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Introduction paragraph.')).toBeVisible();
|
||||
await expect(page.getByText('Final paragraph.')).toBeVisible();
|
||||
await expect(page.getByText(html.paragraphOneText)).toBeVisible();
|
||||
|
||||
// Initially, interactive editor should be hidden, static code view should be visible
|
||||
await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('pre code')
|
||||
.filter({ hasText: 'console.log("Toggle test");' })
|
||||
page.locator('pre code').filter({ hasText: html.codeSnippet })
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('pre code').filter({ hasText: '<div>HTML content</div>' })
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.evaluate(() => localStorage.getItem('showInteractiveEditor'))
|
||||
).resolves.toBe(null);
|
||||
|
||||
// Click the toggle button
|
||||
const toggleButton = page.getByRole('button', {
|
||||
@@ -155,11 +55,10 @@ test.describe('Interactive Editor', () => {
|
||||
await toggleButton.click();
|
||||
|
||||
// Interactive editor should be visible, static code view hidden
|
||||
await expect(page.getByTestId('sp-interactive-editor')).toBeVisible();
|
||||
await expect(page.locator('pre code')).not.toBeVisible();
|
||||
await expect(
|
||||
page.evaluate(() => localStorage.getItem('showInteractiveEditor'))
|
||||
).resolves.toBe('true');
|
||||
page.getByTestId('sp-interactive-editor').first()
|
||||
).toBeVisible();
|
||||
await expect(page.locator('pre code')).not.toBeVisible();
|
||||
|
||||
// Click the toggle button again
|
||||
await toggleButton.click();
|
||||
@@ -167,69 +66,39 @@ test.describe('Interactive Editor', () => {
|
||||
// Interactive editor should be hidden, static code view visible again
|
||||
await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('pre code')
|
||||
.filter({ hasText: 'console.log("Toggle test");' })
|
||||
page.locator('pre code').filter({ hasText: html.codeSnippet })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('pre code').filter({ hasText: '<div>HTML content</div>' })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.evaluate(() => localStorage.getItem('showInteractiveEditor'))
|
||||
).resolves.toBe('false');
|
||||
});
|
||||
|
||||
test('should hide console panel in JS-only interactive editor to prevent output duplication', async ({
|
||||
test('should persist the preference for showing the interactive editor', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.route(
|
||||
`**/page-data${challengePath}/page-data.json`,
|
||||
async route => {
|
||||
const response = await route.fetch();
|
||||
const body = await response.text();
|
||||
const pageData = JSON.parse(body) as PageData;
|
||||
await page.goto(html.challengePath);
|
||||
|
||||
pageData.result.data.challengeNode.challenge.title = challengeTitle;
|
||||
pageData.result.data.challengeNode.challenge.nodules = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
data: '<p>This challenge has only JavaScript code.</p>'
|
||||
},
|
||||
{
|
||||
type: 'interactiveEditor',
|
||||
data: [
|
||||
{
|
||||
contents: 'console.log("Hello from JS-only editor");',
|
||||
ext: 'js',
|
||||
name: 'script-1',
|
||||
contentsHtml:
|
||||
'<pre><code class="language-javascript">console.log("Hello from JS-only editor");</code></pre>'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
// Initially, the interactive editor should be hidden
|
||||
await expect(page.getByTestId('sp-interactive-editor')).not.toBeVisible();
|
||||
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(pageData)
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
await page.goto(challengePath);
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: /interactive editor/i
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: challengeTitle })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('This challenge has only JavaScript code.')
|
||||
page.getByTestId('sp-interactive-editor').first()
|
||||
).toBeVisible();
|
||||
|
||||
// Reload the page and check that the interactive editor is still shown
|
||||
await page.reload();
|
||||
await expect(
|
||||
page
|
||||
.locator('pre code')
|
||||
.filter({ hasText: 'console.log("Hello from JS-only editor");' })
|
||||
page.getByTestId('sp-interactive-editor').first()
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should hide the preview panel in JS-only interactive editors and just show the console', async ({
|
||||
page
|
||||
}) => {
|
||||
await page.goto(js.challengePath);
|
||||
|
||||
// Click the toggle button to show interactive editor
|
||||
await page
|
||||
@@ -238,8 +107,15 @@ test.describe('Interactive Editor', () => {
|
||||
})
|
||||
.click();
|
||||
|
||||
// Check that the console is visible and the console wrapper is hidden
|
||||
await expect(page.locator('.sp-console')).toBeVisible();
|
||||
await expect(page.locator('.sp-console-wrapper')).not.toBeVisible();
|
||||
// Check that the consoles are visible
|
||||
const consoles = page.getByTestId('sp-console');
|
||||
await expect(consoles).toHaveCount(4);
|
||||
for (const console of await consoles.all()) {
|
||||
await expect(console).toBeVisible();
|
||||
}
|
||||
|
||||
// Check that the preview is not visible
|
||||
const previews = page.getByTestId('sp-preview');
|
||||
await expect(previews).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,13 +27,13 @@ function plugin() {
|
||||
) {
|
||||
return {
|
||||
type: 'interactiveEditor',
|
||||
data: getFiles(node.children)
|
||||
files: getFiles(node.children)
|
||||
};
|
||||
} else {
|
||||
const paragraph = mdastToHTML([node]);
|
||||
return {
|
||||
type: 'paragraph',
|
||||
data: paragraph
|
||||
contents: paragraph
|
||||
};
|
||||
}
|
||||
}) ?? [];
|
||||
|
||||
@@ -21,6 +21,23 @@ describe('add-interactive-editor plugin', () => {
|
||||
expect(Array.isArray(file.data.nodules)).toBe(true);
|
||||
});
|
||||
|
||||
it('converts paragraphs to paragraph nodes', async () => {
|
||||
const mockAST = await parseFixture('with-interactive.md');
|
||||
plugin(mockAST, file);
|
||||
|
||||
expect(file.data.nodules.slice(0, 2)).toEqual([
|
||||
{
|
||||
type: 'paragraph',
|
||||
contents: '<p>Normal markdown</p>'
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
contents:
|
||||
'<pre><code class="language-html"><div>This is NOT an interactive element</div>\n</code></pre>'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('populates `nodules` with editor objects', async () => {
|
||||
const mockAST = await parseFixture('with-interactive.md');
|
||||
plugin(mockAST, file);
|
||||
@@ -31,7 +48,7 @@ describe('add-interactive-editor plugin', () => {
|
||||
expect(editorElements).toEqual([
|
||||
{
|
||||
type: 'interactiveEditor',
|
||||
data: [
|
||||
files: [
|
||||
{
|
||||
contents: "console.log('Interactive JS');",
|
||||
ext: 'js',
|
||||
@@ -43,7 +60,7 @@ describe('add-interactive-editor plugin', () => {
|
||||
},
|
||||
{
|
||||
type: 'interactiveEditor',
|
||||
data: [
|
||||
files: [
|
||||
{
|
||||
contents: '<div>This is an interactive element</div>',
|
||||
ext: 'html',
|
||||
@@ -55,7 +72,7 @@ describe('add-interactive-editor plugin', () => {
|
||||
},
|
||||
{
|
||||
type: 'interactiveEditor',
|
||||
data: [
|
||||
files: [
|
||||
{
|
||||
contents: '<div>This is an interactive element</div>',
|
||||
ext: 'html',
|
||||
@@ -84,7 +101,7 @@ describe('add-interactive-editor plugin', () => {
|
||||
|
||||
expect(editorElements).toHaveLength(1);
|
||||
|
||||
const files = editorElements[0].data;
|
||||
const { files } = editorElements[0];
|
||||
expect(files).toHaveLength(2);
|
||||
|
||||
// Both files should be JavaScript but have unique names
|
||||
|
||||
@@ -26,20 +26,6 @@ const idToFilepath = new Map();
|
||||
// recently overwritten files
|
||||
const idToOverwrittenFile = new Map();
|
||||
|
||||
exports.createSchemaCustomization = ({ actions }) => {
|
||||
const { createTypes } = actions;
|
||||
const typeDefs = `
|
||||
type QuizQuestion {
|
||||
text: String!
|
||||
distractors: [String!]!
|
||||
answer: String!
|
||||
audioId: String
|
||||
transcript: String
|
||||
}
|
||||
`;
|
||||
createTypes(typeDefs);
|
||||
};
|
||||
|
||||
exports.sourceNodes = function sourceChallengesSourceNodes(
|
||||
{ actions, reporter, createNodeId, createContentDigest },
|
||||
pluginOptions
|
||||
|
||||
Reference in New Issue
Block a user