diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f8a7885d..8c195b75f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,32 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} - run: echo ${{ github.event.number }} > ./pr-number.txt - - run: pnpm turbo build build:prod --force + - run: pnpm turbo build --force + + build_tests: + name: Build for Tests + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [install_dependencies] + + steps: + - uses: wyvox/action@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: pnpm turbo build:test + + build_prod: + name: Build for Production + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: [install_dependencies] + + steps: + - uses: wyvox/action@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - run: echo ${{ github.event.number }} > ./pr-number.txt + - run: pnpm turbo build:prod - run: ls -la ./apps/repl/dist - run: ls -la ./apps/tutorial/dist # Used for faster deploy so we don't need to checkout the repo @@ -54,6 +79,8 @@ jobs: !node_modules/ !./**/node_modules/ + + ############################################################## lints: @@ -71,34 +98,19 @@ jobs: ############################################################## - - tests_node: - name: "Node Tests" - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: [install_dependencies] - - steps: - - uses: wyvox/action@v1 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - - name: Test - run: pnpm turbo test:node - - tests: - name: "Ember Tests" + name: "Tests" strategy: + fail-fast: false matrix: - # os: [ubuntu-latest, macOS-latest, windows-latest] - # browsers: [chrome, firefox, safari, edge] - ci_browser: - - Chrome - - Firefox + environment: + - chrome + - firefox + - node runs-on: ubuntu-latest timeout-minutes: 10 - needs: [install_dependencies] + needs: [build_tests] steps: - uses: wyvox/action@v1 @@ -111,27 +123,7 @@ jobs: echo "Node: $( node --version )" echo "NPM: $( npm --version )" echo "pnpm: $( pnpm --version )" - - name: Test - run: pnpm turbo test:ember - env: - CI_BROWSER: ${{ matrix.ci_browser }} - - # try-scenarios: - # name: "Test try-scenarios" - # runs-on: ubuntu-latest - # timeout-minutes: 15 - # needs: [tests] - # # I need to figure out a better way to handle this with turbo - # continue-on-error: true - # steps: - # - uses: actions/checkout@v3 - # - name: TurboRepo local server - # uses: felixmosh/turborepo-gh-artifacts@v2 - # with: - # repo-token: ${{ secrets.GITHUB_TOKEN }} - # - uses: NullVoxPopuli/action-setup-pnpm@v2.0.0 - # - name: Test - # run: pnpm turbo test:scenarios + - run: pnpm turbo test:${{ matrix.environment }} browserstack-test: @@ -169,7 +161,7 @@ jobs: if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest timeout-minutes: 15 - needs: [browserstack-test] + needs: [browserstack-test, build_prod] strategy: matrix: app: diff --git a/1 b/1 new file mode 100644 index 000000000..002b8e717 --- /dev/null +++ b/1 @@ -0,0 +1,345 @@ +import rehypeShiki from '@shikijs/rehype'; +import { stripIndent } from 'common-tags'; +import { visit } from 'unist-util-visit'; +import { describe, expect as errorExpect, it } from 'vitest'; + +import { parseMarkdown } from './parse.js'; + +const expect = errorExpect.soft; + +function assertOutput(actual: string, expected: string) { + const _actual = actual?.split('\n')?.filter(Boolean)?.join('\n')?.trim(); + const _expected = expected.split('\n').filter(Boolean).join('\n').trim(); + + expect(_actual).toBe(_expected); +} + +const ALLOWED_FORMATS = ['gjs', 'jsx', 'vue', 'svelte', 'hbs']; +const defaults = { + needsLive: (lang: string) => { + if (!ALLOWED_FORMATS.includes(lang)) return false; + + return true; + }, + isPreview: (meta: string) => meta?.includes('preview'), + isBelow: (meta: string) => meta?.includes('below'), + isLive: (meta: string, lang: string) => + meta?.includes('live') || (!meta && ALLOWED_FORMATS.includes(lang)), + ALLOWED_FORMATS, +}; + +describe('options', () => { + describe('remarkPlugins', () => { + it('works', async () => { + const result = await parseMarkdown(`# Title`, { + ...defaults, + remarkPlugins: [ + function noH1(/* options */) { + return (tree) => { + return visit(tree, ['heading'], function (node) { + if (!('depth' in node)) return; + + if (node.depth === 1) { + node.depth = 2; + } + + return 'skip'; + }); + }; + }, + ], + }); + + expect(result.templateOnlyGlimdown).toBe('

Title

'); + expect(result.blocks).to.deep.equal([]); + }); + + it('w/ options', async () => { + const result = await parseMarkdown(`# Title`, { + ...defaults, + remarkPlugins: [ + [ + function noH1(options: { depth: number }) { + return (tree) => { + return visit(tree, ['heading'], function (node) { + if (!('depth' in node)) return; + + if (node.depth === 1) { + node.depth = options.depth; + } + + return 'skip'; + }); + }; + }, + { depth: 3 }, + ], + ], + }); + + expect(result.templateOnlyGlimdown).toBe('

Title

'); + expect(result.blocks).to.deep.equal([]); + }); + }); + + describe('rehypePlugins', () => { + it('works', async () => { + const result = await parseMarkdown(`# Title`, { + ...defaults, + rehypePlugins: [ + function noH1(/* options */) { + return (tree) => { + return visit(tree, ['element'], function (node) { + if (!('tagName' in node)) return; + + if (node.tagName === 'h1') { + node.tagName = 'h2'; + } + + return 'skip'; + }); + }; + }, + ], + }); + + expect(result.templateOnlyGlimdown).toBe('

Title

'); + expect(result.blocks).to.deep.equal([]); + }); + + it('w/ options', async () => { + const result = await parseMarkdown(`# Title`, { + ...defaults, + rehypePlugins: [ + [ + function noH1(options: { depth: number }) { + return (tree) => { + return visit(tree, ['element'], function (node) { + if (!('tagName' in node)) return; + + if (node.tagName === 'h1') { + node.tagName = `h${options.depth ?? 2}`; + } + + return 'skip'; + }); + }; + }, + { depth: 3 }, + ], + ], + }); + + expect(result.templateOnlyGlimdown).toBe('

Title

'); + expect(result.blocks).to.deep.equal([]); + }); + + it('retains {{ }} escaping', async () => { + const result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`gjs + const two = 2 + + + \`\`\` + `, + { + ...defaults, + rehypePlugins: [[rehypeShiki, { theme: 'github-dark' }]], + } + ); + + assertOutput( + result.templateOnlyGlimdown, + `

Title

+
const two = 2
+
+<template>
+  \\{{two}}
+</template>
` + ); + }); + }); + + describe('codefences', () => { + describe('hbs', () => { + it('Code fence is live', async () => { + const snippet = `{{concat "hello" " " "there"}}`; + const result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`hbs live + ${snippet} + \`\`\` + `.trim() + ); + + expect(result.templateOnlyGlimdown).toMatchInlineSnapshot(); + + expect(result.blocks).to.deep.equal([ + { + code: snippet, + name, + lang: 'hbs', + }, + ]); + }); + }); + + describe('gjs', () => { + it('Code fence does not have the "live" keyword', async function (assert) { + const result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`gjs + const two = 2; + \`\`\` + `, + { CopyComponent: '', ...defaults } + ); + + assertOutput( + result.templateOnlyGlimdown, + stripIndent` +

Title

+ +
  const two = 2;
+          
+ ` + ); + + assert.deepEqual(result.blocks, []); + }); + + it('Code fence is live', async function (assert) { + const snippet = `const two = 2`; + const result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`gjs live + ${snippet} + \`\`\` + `, + { ...defaults } + ); + + expect(result.templateOnlyGlimdown).toMatchInlineSnapshot(); + + expect(result.blocks).to.deep.equal([ + { + code: snippet, + name, + lang: 'gjs', + }, + ]); + }); + + it('Code with preview fence has {{ }} tokens escaped', async function () { + const result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`gjs + const two = 2 + + + \`\`\` + `, + { ...defaults } + ); + + assertOutput( + result.templateOnlyGlimdown, + stripIndent` +

Title

+ +
const two = 2
+
+          <template>
+            \\{{two}}
+          </template>
+          
+ ` + ); + }); + + it('Inline Code with {{ }} tokens is escaped', async function () { + const result = await parseMarkdown( + stripIndent` + # Title + + \`{{ foo }}\` + `, + { ...defaults } + ); + + assertOutput( + result.templateOnlyGlimdown, + stripIndent` +

Title

+

\\{{ foo }}

+ ` + ); + }); + + it('Can invoke a component again when defined in a live fence', async function (assert) { + const snippet = `const two = 2`; + const result = await parseMarkdown( + stripIndent` + # Title + + \`\`\`gjs live + ${snippet} + \`\`\` + + `, + { ...defaults } + ); + + expect(result.templateOnlyGlimdown).toMatchInlineSnapshot(); + + expect(result.blocks).to.deep.equal([ + { + code: snippet, + name, + lang: 'gjs', + }, + ]); + }); + + it('Code fence imports things', async function (assert) { + const snippet = stripIndent` + import Component from '@glimmer/component'; + import { on } from '@ember/modifier'; + + + `; + const result = await parseMarkdown( + `hi\n` + `\n` + '```gjs live preview\n' + snippet + '\n```', + { CopyComponent: '', ...defaults } + ); + + expect(result.templateOnlyGlimdown).toMatchInlineSnapshot(); + + expect(result.blocks).to.deep.equal([ + { + code: snippet, + name, + lang: 'gjs', + }, + ]); + }); + }); + }); +}); diff --git a/apps/repl/.template-lintrc.cjs b/apps/repl/.template-lintrc.cjs index be2758884..20e5f9447 100644 --- a/apps/repl/.template-lintrc.cjs +++ b/apps/repl/.template-lintrc.cjs @@ -16,5 +16,12 @@ module.exports = { 'no-forbidden-elements': 'off', }, }, + { + files: ['**/languages.gts'], + rules: { + 'no-triple-curlies': 'off', + 'no-inline-styles': 'off', + }, + }, ], }; diff --git a/apps/repl/app/app.ts b/apps/repl/app/app.ts index 6f13e5321..55c9a37c8 100644 --- a/apps/repl/app/app.ts +++ b/apps/repl/app/app.ts @@ -6,7 +6,7 @@ import Application from '@ember/application'; import Resolver from 'ember-resolver'; -import config from 'limber/config/environment'; +import config from '#config'; import { registry } from './registry.ts'; @@ -22,4 +22,10 @@ Object.assign(window, { export default class App extends Application { modulePrefix = config.modulePrefix; Resolver = Resolver.withModules(registry); + + // LOG_RESOLVER = true; + // LOG_ACTIVE_GENERATION = true; + // LOG_TRANSITIONS = true; + // LOG_TRANSITIONS_INTERNAL = true; + // LOG_VIEW_LOOKUPS = true; } diff --git a/apps/repl/app/boot.ts b/apps/repl/app/boot.ts new file mode 100644 index 000000000..3746a9563 --- /dev/null +++ b/apps/repl/app/boot.ts @@ -0,0 +1,5 @@ +import environment from '#config'; + +import Application from './app.ts'; + +Application.create(environment.APP); diff --git a/apps/repl/app/components/clear-error.ts b/apps/repl/app/components/clear-error.ts new file mode 100644 index 000000000..075b36e36 --- /dev/null +++ b/apps/repl/app/components/clear-error.ts @@ -0,0 +1,15 @@ +import { resource, resourceFactory } from 'ember-resources'; + +import type StatusService from '#app/services/status.ts'; + +export function clearError(_invalidator: unknown) { + return resource(({ owner }) => { + const status = owner.lookup('service:status') as StatusService; + + status?.hideError(); + + return ''; + }); +} + +resourceFactory(clearError); diff --git a/apps/repl/app/components/compiler.gts b/apps/repl/app/components/compiler.gts new file mode 100644 index 000000000..21db71e4c --- /dev/null +++ b/apps/repl/app/components/compiler.gts @@ -0,0 +1,39 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; + +import { Compiled } from 'ember-repl'; + +import type { CompileState, Format } from 'ember-repl'; +import type EditorService from 'limber/services/editor'; + +interface Signature { + Blocks: { + default: [CompileState]; + }; +} + +export default class Compiler extends Component { + + + @service declare editor: EditorService; + + get formatQP() { + return this.editor.format; + } + + get formatQPParts() { + return this.formatQP.split('|'); + } + + get format() { + return this.formatQPParts[0] as Format; + } + + get flavor() { + return this.formatQPParts[1] as string | undefined; + } +} diff --git a/apps/repl/app/components/limber/copy-menu.gts b/apps/repl/app/components/copy-menu.gts similarity index 96% rename from apps/repl/app/components/limber/copy-menu.gts rename to apps/repl/app/components/copy-menu.gts index 3a32c2070..88bcb9a7e 100644 --- a/apps/repl/app/components/limber/copy-menu.gts +++ b/apps/repl/app/components/copy-menu.gts @@ -1,8 +1,8 @@ import Component from '@glimmer/component'; import { on } from '@ember/modifier'; -import { copyToClipboard, getSnippetElement } from './copy-utils'; -import Menu from './menu'; +import { copyToClipboard, getSnippetElement } from './copy-utils.ts'; +import Menu from './menu.gts'; /** * This component is injected via the markdown rendering diff --git a/apps/repl/app/components/limber/copy-utils.ts b/apps/repl/app/components/copy-utils.ts similarity index 94% rename from apps/repl/app/components/limber/copy-utils.ts rename to apps/repl/app/components/copy-utils.ts index 46d18ae9c..01a2aa3a5 100644 --- a/apps/repl/app/components/limber/copy-utils.ts +++ b/apps/repl/app/components/copy-utils.ts @@ -23,15 +23,11 @@ export function getSnippetElement(event: Event) { return element; } - if (element.getAttribute('data-test-output')) { + if (element.hasAttribute('data-test-output')) { return element; } - if (element.getAttribute('data-test-compiled-output')) { - return element; - } - - if (element === document.body) { + if (element.hasAttribute('data-test-compiled-output')) { return element; } } diff --git a/apps/repl/app/components/limber/frame-output.gts b/apps/repl/app/components/limber/frame-output.gts deleted file mode 100644 index de1b7f144..000000000 --- a/apps/repl/app/components/limber/frame-output.gts +++ /dev/null @@ -1,173 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { isDestroyed, isDestroying, registerDestructor } from '@ember/destroyable'; -import { action } from '@ember/object'; -import { service } from '@ember/service'; -import { buildWaiter, waitFor, waitForPromise } from '@ember/test-waiters'; - -import { modifier } from 'ember-modifier'; -import { type Connection, connectToChild } from 'penpal'; - -import { fileFromParams, type Format, type OutputError } from 'limber/utils/messaging'; - -import type Owner from '@ember/owner'; -import type RouterService from '@ember/routing/router-service'; -import type EditorService from 'limber/services/editor'; - -const compileWaiter = buildWaiter('::compile'); -const compileTokens: unknown[] = []; - -type FrameStatus = 'disconnected' | 'connected'; - -/** - * The Receiving Component is Limber::Output::Compiler - */ -export default class FrameOutput extends Component { - @service declare editor: EditorService; - @service declare router: RouterService; - - @tracked frameStatus: FrameStatus = 'disconnected'; - @tracked hadUnrecoverableError = false; - - connection?: Connection<{ - update: (format: Format, text: string) => void; - }>; - - constructor(owner: Owner, args: any) { - super(owner, args); - - registerDestructor(this, () => { - this.connection?.destroy(); - }); - } - - @action - @waitFor - async queuePayload() { - const qps = this.router.currentURL?.split('?')[1]; - - if (!this.connection) return; - - const child = await this.connection.promise; - - if (isDestroyed(this) || isDestroying(this)) return; - - if (this.frameStatus === 'disconnected') { - console.warn('Frame is disconnected, not sending payload'); - - return; - } - - const { text, format } = fileFromParams(qps); - - if (text && format) { - await child.update(format, text); - } - - compileTokens.push(compileWaiter.beginAsync()); - } - - /** - * HMM.... statechart - */ - previous = ''; - previousURL = ''; - monitorConnection = modifier((element: HTMLIFrameElement) => { - const status = this.frameStatus; - const currentURL = this.router.currentURL; - - if (!currentURL) return; - - if (status === 'connected' && currentURL !== this.previousURL) { - this.previous = status; - this.previousURL = currentURL; - - return this.postMessage(element); - } - - if (status !== this.previous) { - this.previous = status; - this.previousURL = currentURL; - - switch (status) { - case 'disconnected': { - this.connectToOutput(element); - - break; - } - case 'connected': { - this.postMessage(element); - } - } - } - }); - - /** - * We have to reload the output frame - */ - setNoError = async (element: HTMLIFrameElement) => { - this.connection?.destroy(); - element.src = `/output`; - await Promise.resolve(); - this.frameStatus = 'disconnected'; - this.hadUnrecoverableError = false; - }; - - /** - * We can't post right away, because we might do so before the iframe is ready. - * We need to wait until the frame initiates contact. - */ - postMessage = (element: HTMLIFrameElement) => { - if (this.hadUnrecoverableError) { - this.setNoError(element); - - return; - } - - this.queuePayload(); - }; - - connectToOutput = (element: HTMLIFrameElement) => { - this.connection = connectToChild({ - iframe: element, - methods: { - error: (obj: OutputError) => { - this.editor.error = obj.error; - this.editor.isCompiling = false; - - if ('unrecoverable' in obj) { - this.hadUnrecoverableError = true; - } - }, - beginCompile: () => { - compileTokens.push(compileWaiter.beginAsync()); - this.editor.isCompiling = true; - }, - success: () => { - this.editor.error = undefined; - this.editor.isCompiling = false; - }, - finishedRendering: () => { - compileTokens.forEach((token) => compileWaiter.endAsync(token)); - }, - }, - }); - - /** - * It's important to change the frameStatus so that - * postMessage can run again with a connection - */ - waitForPromise(this.connection.promise) - .then(() => (this.frameStatus = 'connected')) - .catch(console.error); - }; - - -} diff --git a/apps/repl/app/components/limber/layout/building.gts b/apps/repl/app/components/limber/layout/building.gts deleted file mode 100644 index c8707308b..000000000 --- a/apps/repl/app/components/limber/layout/building.gts +++ /dev/null @@ -1,14 +0,0 @@ -import { service } from 'ember-primitives/helpers/service'; - - diff --git a/apps/repl/app/components/limber/layout/controls/format-menu.gts b/apps/repl/app/components/limber/layout/controls/format-menu.gts deleted file mode 100644 index c73e04410..000000000 --- a/apps/repl/app/components/limber/layout/controls/format-menu.gts +++ /dev/null @@ -1,71 +0,0 @@ -import Component from '@glimmer/component'; -import { fn } from '@ember/helper'; -import { on } from '@ember/modifier'; -import { service } from '@ember/service'; - -import Menu from 'limber/components/limber/menu'; - -import type RouterService from '@ember/routing/router-service'; -import type { Format } from 'limber/utils/messaging'; - -function abbreviationFor(format: Format) { - return format === 'glimdown' ? 'gdm' : format; -} - -function iconFor(format: Format): string { - switch (format) { - case 'glimdown': - return 'G⬇'; - case 'gjs': - return 'GJS'; - case 'hbs': - return 'HBS'; - } -} - -const menuIconClasses = `inline-block bg-ember-black text-white text-xs px-1 rounded w-8 text-center`; - -export class FormatMenu extends Component<{ Element: HTMLButtonElement }> { - @service declare router: RouterService; - - switch = (format: Format): void => { - this.router.transitionTo({ queryParams: { format } }); - }; - - isSelected = (format: Format) => { - const fmt = abbreviationFor(this.format); - - return fmt === format; - }; - - get format(): Format { - return this.router.currentRoute?.queryParams?.format as Format; - } - - -} diff --git a/apps/repl/app/components/limber/layout/error.gts b/apps/repl/app/components/limber/layout/error.gts deleted file mode 100644 index b646588ed..000000000 --- a/apps/repl/app/components/limber/layout/error.gts +++ /dev/null @@ -1,24 +0,0 @@ -import Component from '@glimmer/component'; -import { service } from '@ember/service'; - -import type EditorService from 'limber/services/editor'; - -interface Signature { - Element: HTMLElement | null; -} - -export default class EditorError extends Component { - @service declare editor: EditorService; - - -} diff --git a/apps/repl/app/components/limber/output/compiler/import-map.ts b/apps/repl/app/components/limber/output/compiler/import-map.ts deleted file mode 100644 index 83f83d178..000000000 --- a/apps/repl/app/components/limber/output/compiler/import-map.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ -// Some packages do not provide types - -import * as eDeepTracked from 'ember-deep-tracked'; -// @ts-expect-error -import * as focusTrap from 'ember-focus-trap'; -import * as eModifier from 'ember-modifier'; -import * as ePrimitives from 'ember-primitives'; -import * as emberRepl from 'ember-repl'; -import * as eResources from 'ember-resources'; -import * as reactiveDebounce from 'reactiveweb/debounce'; -import * as reactiveFps from 'reactiveweb/fps'; -import * as reactiveFunction from 'reactiveweb/function'; -import * as reactiveImage from 'reactiveweb/image'; -import * as reactiveKeepLatest from 'reactiveweb/keep-latest'; -import * as reactiveLink from 'reactiveweb/link'; -import * as reactiveMap from 'reactiveweb/map'; -import * as reactiveRemoteData from 'reactiveweb/remote-data'; -import * as reactiveModifier from 'reactiveweb/resource/modifier'; -import * as reactiveService from 'reactiveweb/resource/service'; -import * as reactiveSync from 'reactiveweb/sync'; -import * as reactiveThrottle from 'reactiveweb/throttle'; -import * as reactiveWaitUntil from 'reactiveweb/wait-until'; -import * as trackedBuiltIns from 'tracked-built-ins'; -// @ts-expect-error -import * as trackedToolbox from 'tracked-toolbox'; -import * as xstate from 'xstate'; - -import * as limberHeader from 'limber/components/limber/header'; -import * as limberMenu from 'limber/components/limber/menu'; -import * as limberUi from 'limber-ui'; -import { ExternalLink } from 'limber-ui'; - -export const importMap = { - // Own Stuff - // Something is weird with how this import map works. - // I can't wait to land the other which behaves a lot mormally - 'limber/components/limber/menu': Object.assign(limberMenu.default, limberMenu), - 'limber/components/limber/header': Object.assign(limberHeader.default, limberHeader), - - // Legacy things that don't exist anymore - 'limber/helpers/state': (...args: unknown[]) => { - const c = eResources.cell(...args); - - return { - ...c, - // @ts-ignore - increment: () => c.current++, - get value() { - return c.current; - }, - }; - }, - - // Libraries - 'ember-repl': emberRepl, - 'limber-ui': limberUi, - xstate: xstate, - 'ember-modifier': eModifier, - 'tracked-built-ins': trackedBuiltIns, - 'ember-focus-trap': focusTrap, - 'ember-primitives': ePrimitives, - 'tracked-toolbox': trackedToolbox, - 'ember-deep-tracked': eDeepTracked, - 'ember-resources': eResources, - - // Reactiveweb *only* has path imports - 'reactiveweb/debounce': reactiveDebounce, - 'reactiveweb/ember-concurrency': reactiveDebounce, - 'reactiveweb/fps': reactiveFps, - 'reactiveweb/function': reactiveFunction, - 'reactiveweb/image': reactiveImage, - 'reactiveweb/keep-latest': reactiveKeepLatest, - 'reactiveweb/link': reactiveLink, - 'reactiveweb/map': reactiveMap, - 'reactiveweb/remote-data': reactiveRemoteData, - 'reactiveweb/modifier': reactiveModifier, - 'reactiveweb/service': reactiveService, - 'reactiveweb/sync': reactiveSync, - 'reactiveweb/throttle': reactiveThrottle, - 'reactiveweb/wait-until': reactiveWaitUntil, -}; - -function defineWithWarning( - obj: object, - { name, original, replacement }: { name?: string; original: string; replacement?: string } -) { - Object.defineProperty(importMap, original, { - get() { - const suggestion = replacement - ? `Please use ${replacement} going forward.` - : `There is not a direct replacement, please consult the docs for the library you're trying to use.`; - - if (name) { - console.warn( - `${name} is no longer located at ${original} and has been aliased for you. ${suggestion}` - ); - } else { - console.warn( - `The import you are using at ${original} no longerg exists and has been aliased for you. ${suggestion}` - ); - } - - return obj; - }, - }); -} - -/** - * These paths are for backcompat - * Since code is maintained in URLs, - * we can't upgrade any of it. - * - * We could probably log a deprecation message on these paths - */ -defineWithWarning(ExternalLink, { - name: '', - original: 'limber/components/external-link', - replacement: 'limber-ui', -}); -defineWithWarning( - { default: ePrimitives.Shadowed, Shadowed: ePrimitives.Shadowed }, - { name: '', original: 'limber/components/shadowed', replacement: 'ember-primitives' } -); -defineWithWarning(eResources, { original: 'ember-resources/core', replacement: 'ember-resources' }); -defineWithWarning(reactiveLink, { - name: 'link', - original: 'ember-resources/link', - replacement: 'reactiveweb/link', -}); -defineWithWarning(reactiveService, { - name: 'service', - original: 'ember-resources/service', - replacement: 'reactiveweb/resource/service', -}); -defineWithWarning(reactiveModifier, { - name: 'modifier', - original: 'ember-resources/modifier', - replacement: 'reactiveweb/resource/modifier', -}); -defineWithWarning(reactiveMap, { - name: 'map', - original: 'ember-resources/util/map', - replacement: 'reactiveweb/map', -}); -defineWithWarning(reactiveDebounce, { - name: 'debounce', - original: 'ember-resources/util/debounce', - replacement: 'reactiveweb/debounce', -}); -defineWithWarning(reactiveKeepLatest, { - name: 'keepLatest', - original: 'ember-resources/util/keep-latest', - replacement: 'reactiveweb/keep-latest', -}); -defineWithWarning(reactiveFunction, { - name: 'function', - original: 'ember-resources/util/function', - replacement: 'reactiveweb/function', -}); -defineWithWarning(reactiveFps, { - name: 'FrameRate or UpdateFrequency', - original: 'ember-resources/util/fps', - replacement: 'reactiveweb/fps', -}); -defineWithWarning(reactiveRemoteData, { - name: 'RemoteData', - original: 'ember-resources/util/remote-data', - replacement: 'reactiveweb/remote-data', -}); -defineWithWarning(focusTrap.focusTrap, { - name: 'focusTrap', - original: 'ember-focus-trap/modifiers/focus-trap', - replacement: 'ember-focus-trap', -}); diff --git a/apps/repl/app/components/limber/output/compiler/index.gts b/apps/repl/app/components/limber/output/compiler/index.gts deleted file mode 100644 index c71f78c7d..000000000 --- a/apps/repl/app/components/limber/output/compiler/index.gts +++ /dev/null @@ -1,131 +0,0 @@ -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { isDestroyed, isDestroying } from '@ember/destroyable'; -import { hash } from '@ember/helper'; -import { action } from '@ember/object'; -import { schedule } from '@ember/runloop'; -import { service } from '@ember/service'; -import { waitFor } from '@ember/test-waiters'; - -import { compile, type EvalImportMap } from 'ember-repl'; - -import CopyMenu from 'limber/components/limber/copy-menu'; - -import type { MessagingAPI, Parent } from '../frame-messaging'; -import type Owner from '@ember/owner'; -import type RouterService from '@ember/routing/router-service'; -import type { ComponentLike } from '@glint/template'; -import type { Format } from 'limber/utils/messaging'; - -interface Signature { - Args: { - messagingAPI: MessagingAPI; - }; - Blocks: { - default: [ - { - component: ComponentLike | undefined; - format: Format | undefined; - }, - ]; - }; -} - -/** - * The Receiving Component is Limber::FrameOutput - */ -export default class Compiler extends Component { - - - @service declare router: RouterService; - - @tracked component?: ComponentLike; - @tracked error: string | null = null; - @tracked errorLine: number | null = null; - @tracked template?: unknown; - /** - * Used for changing default styles, if needed - */ - @tracked format?: Format; - - declare parentFrame: Parent; - - constructor(owner: Owner, args: Signature['Args']) { - super(owner, args); - - const api = args.messagingAPI; - - api.onReceiveText((format: Format, text) => this.makeComponent(format, text)); - api.onConnect((parent) => (this.parentFrame = parent)); - } - - onCompileStart = async () => { - await this.parentFrame.beginCompile(); - }; - - onError = async (error: string) => { - await this.parentFrame.error({ error }); - }; - - @action - @waitFor - async makeComponent(format: Format, text: string) { - const { importMap } = await import('./import-map'); - - const onSuccess = async (component: ComponentLike) => { - if (!component) { - await this.parentFrame.error({ error: 'could not build component' }); - - return; - } - - if (isDestroyed(this) || isDestroying(this)) return; - - this.component = component as ComponentLike; - this.format = format; - - await this.parentFrame.success(); - - // eslint-disable-next-line ember/no-runloop - schedule('afterRender', () => { - if (isDestroyed(this) || isDestroying(this)) return; - - this.parentFrame.finishedRendering(); - }); - }; - - switch (format) { - case 'glimdown': - return await compile(text, { - format: format, - CopyComponent: '', - topLevelScope: { - CopyMenu: CopyMenu, - }, - importMap: importMap as unknown as EvalImportMap, - onCompileStart: this.onCompileStart, - onSuccess, - onError: this.onError, - }); - - case 'gjs': - return await compile(text, { - format: format, - importMap: importMap as unknown as EvalImportMap, - onCompileStart: this.onCompileStart, - onSuccess, - onError: this.onError, - }); - case 'hbs': - return await compile(text, { - format: format, - topLevelScope: { - CopyMenu: CopyMenu, - }, - onCompileStart: this.onCompileStart, - onSuccess, - onError: this.onError, - }); - } - } -} diff --git a/apps/repl/app/components/limber/output/frame-messaging.gts b/apps/repl/app/components/limber/output/frame-messaging.gts deleted file mode 100644 index b6d7a8a45..000000000 --- a/apps/repl/app/components/limber/output/frame-messaging.gts +++ /dev/null @@ -1,128 +0,0 @@ -import Ember from 'ember'; -import Component from '@glimmer/component'; -import { tracked } from '@glimmer/tracking'; -import { isDestroyed, isDestroying, registerDestructor } from '@ember/destroyable'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore -import { hash } from '@ember/helper'; -import { service } from '@ember/service'; - -import { type AsyncMethodReturns, type Connection, connectToParent } from 'penpal'; - -import { type Format, type OutputError } from 'limber/utils/messaging'; - -import type RouterService from '@ember/routing/router-service'; -import type { ComponentLike } from '@glint/template'; - -export interface MessagingAPI { - onReceiveText: (callback: (format: Format, text: string) => void) => void; - onConnect: (callback: (parent: AsyncMethodReturns) => void) => void; -} - -export type Parent = AsyncMethodReturns; - -interface Signature { - Blocks: { - default: [MessagingAPI]; - }; -} - -export interface ParentMethods { - ready: () => void; - error: (error: OutputError) => void; - beginCompile: () => void; - success: () => void; - finishedRendering: () => void; -} - -async function setupEvents( - context: Compiler, - { - onReceiveText, - onConnect, - }: { - onReceiveText: (format: Format, text: string) => void; - onConnect: (parent: AsyncMethodReturns) => void; - } -) { - const connection = connectToParent({ - methods: { - update(format: Format, text: string) { - onReceiveText(format, text); - }, - }, - }); - - context.connection = connection; - - registerDestructor(context, () => connection.destroy()); - - const parent = await connection.promise; - - onConnect(parent); - - if (isDestroyed(context) || isDestroying(context)) return; - - /** - * This app now can't render again, so we need to tell the host frame to re-load the output frame - */ - - Ember.onerror = (error: any) => - parent.error({ error: error.message || error, unrecoverable: true }); - - const handleError = (error: any) => parent.error({ error: error.message || error }); - - window.addEventListener('error', handleError); - - registerDestructor(context, () => window.removeEventListener('error', handleError)); - - return connection; -} - -/** - * The Receiving Component is Limber::FrameOutput - * - * The purpose of this class is not *not* use it during testing so we can test the compiler - * end renderer more directly. - * - * Also because testem can't handle iframes, we need to jump through a couple extra hoops. - * - * But this leads to smaller, more focused components, so... maybe that's good. idk. - * - * - */ -export default class Compiler extends Component { - @service declare router: RouterService; - - @tracked component?: ComponentLike; - @tracked error: string | null = null; - @tracked errorLine: number | null = null; - @tracked template?: unknown; - - connection?: Connection; - - _onReceiveText?: (format: Format, text: string) => void; - onReceiveText = (callback: NonNullable) => { - this._onReceiveText = callback; - this.trySetup(); - }; - - _onConnect?: (parent: AsyncMethodReturns) => void; - onConnect = (callback: NonNullable) => { - this._onConnect = callback; - this.trySetup(); - }; - - trySetup = () => { - const { _onReceiveText, _onConnect, connection } = this; - - if (_onReceiveText && _onConnect && !connection) { - setupEvents(this, { - onReceiveText: _onReceiveText, - onConnect: _onConnect, - }); - } - }; - - -} diff --git a/apps/repl/app/components/limber/output/index.gts b/apps/repl/app/components/limber/output/index.gts deleted file mode 100644 index 33a3d8373..000000000 --- a/apps/repl/app/components/limber/output/index.gts +++ /dev/null @@ -1,47 +0,0 @@ -import { PortalTargets } from 'ember-primitives'; - -import highlight from 'limber/modifiers/highlight-code-blocks'; - -import CopyMenu from '../copy-menu'; -import Compiler from './compiler'; - -import type { MessagingAPI } from './frame-messaging'; -import type { TOC } from '@ember/component/template-only'; -import type { Format } from 'limber/utils/messaging'; - -interface Signature { - Args: { - messagingAPI: MessagingAPI; - }; -} - -const isGJS = (format: Format | undefined) => format === 'gjs'; - -export const Output: TOC = ; - -export default Output; diff --git a/apps/repl/app/components/limber/menu.gts b/apps/repl/app/components/menu.gts similarity index 70% rename from apps/repl/app/components/limber/menu.gts rename to apps/repl/app/components/menu.gts index c3c28c061..55e43ab2f 100644 --- a/apps/repl/app/components/limber/menu.gts +++ b/apps/repl/app/components/menu.gts @@ -2,7 +2,9 @@ import { hash } from '@ember/helper'; // @ts-expect-error - they still don't have types import { focusTrap } from 'ember-focus-trap'; +import { Key } from 'ember-primitives/components/keys'; import { Menu as HeadlessMenu } from 'ember-primitives/components/menu'; +import { FloatingUI } from 'ember-primitives/floating-ui'; import type { TOC } from '@ember/component/template-only'; import type { ComponentLike, WithBoundArgs } from '@glint/template'; @@ -10,6 +12,15 @@ import type { ItemSignature, Signature as MenuSignature } from 'ember-primitives type MenuType = MenuSignature['Blocks']['default'][0]; +const keyboardHelp = { + crossAxis: -8, +}; + +const focusTrapOptions = { + clickOutsideDeactivates: true, + allowOutsideClick: true, +}; + const Button: TOC<{ Element: HTMLButtonElement; Args: { @@ -86,7 +97,7 @@ const Menu: TOC<{ @flipOptions={{hash padding=8}} as |menu| > -
+
{{yield (hash menu=menu @@ -96,12 +107,12 @@ const Menu: TOC<{ ) to="trigger" }} - - - {{! template-lint-disable no-inline-styles }} -
+ {{! template-lint-disable no-inline-styles }} +
+ {{menu.arrow}} + >
+ +
+ {{yield (component Button content=content) to="options"}} +
-
- {{yield (component Button content=content) to="options"}} -
+
+ {{#if menu.isOpen}} + + {{/if}} + -
; diff --git a/apps/repl/app/components/output-reset.css b/apps/repl/app/components/output-reset.css new file mode 100644 index 000000000..34dabfc81 --- /dev/null +++ b/apps/repl/app/components/output-reset.css @@ -0,0 +1,9 @@ +h1, h2, h3, h4, h5, h6 { + margin: 0; +} + +pre.shiki { + padding: 1rem; + border-radius: 0.5rem; + box-shadow: inset 0px 1px 1px 1px black; +} diff --git a/apps/repl/app/components/output.gts b/apps/repl/app/components/output.gts new file mode 100644 index 000000000..ea46b31a3 --- /dev/null +++ b/apps/repl/app/components/output.gts @@ -0,0 +1,78 @@ +import { PortalTargets } from 'ember-primitives/components/portal-targets'; +import { Shadowed } from 'ember-primitives/components/shadowed'; +import { castToBoolean, qp } from 'ember-primitives/qp'; +import { Seconds } from 'reactiveweb/interval'; + +import { clearError } from './clear-error.ts'; +import Compiler from './compiler.gts'; +import CopyMenu from './copy-menu.gts'; +import resetsCSS from './output-reset.css?url'; + +import type { TOC } from '@ember/component/template-only'; + +const isGJS = (format: string | undefined) => format === 'gjs'; + +function wantsShadow(arg: boolean | undefined, qp: string | undefined) { + if (qp !== undefined) { + return castToBoolean(qp); + } + + if (arg === undefined) return true; + + return arg; +} + +export const Output: TOC<{ + Args: { + shadow?: boolean; + }; +}> = ; + +export default Output; diff --git a/apps/repl/app/components/limber/save.gts b/apps/repl/app/components/save.gts similarity index 100% rename from apps/repl/app/components/limber/save.gts rename to apps/repl/app/components/save.gts diff --git a/apps/repl/app/config/environment.d.ts b/apps/repl/app/config/environment.d.ts deleted file mode 100644 index 4eb32a809..000000000 --- a/apps/repl/app/config/environment.d.ts +++ /dev/null @@ -1,17 +0,0 @@ -export default config; - -/** - * Type declarations for - * import config from 'tutorial/config/environment' - */ -declare const config: { - environment: string; - modulePrefix: string; - podModulePrefix: string; - locationType: 'history' | 'hash' | 'none' | 'auto'; - rootURL: string; - APP: Record; - SERVICE_WORKER: boolean; -}; - -export function enterTestMode(): void; diff --git a/apps/repl/app/config/environment.js b/apps/repl/app/config/environment.js deleted file mode 100644 index 3e2502eaf..000000000 --- a/apps/repl/app/config/environment.js +++ /dev/null @@ -1,22 +0,0 @@ -import { getGlobalConfig } from '@embroider/macros/src/addon/runtime'; - -const ENV = { - modulePrefix: 'limber', - environment: import.meta.env.DEV ? 'development' : 'production', - rootURL: '/', - locationType: 'history', - EmberENV: {}, - APP: {}, -}; - -export default ENV; - -export function enterTestMode() { - ENV.locationType = 'none'; - ENV.APP.rootElement = '#ember-testing'; - ENV.APP.autoboot = false; - - let config = getGlobalConfig()['@embroider/macros']; - - if (config) config.isTesting = true; -} diff --git a/apps/repl/app/config/environment.ts b/apps/repl/app/config/environment.ts new file mode 100644 index 000000000..7a2a48c0c --- /dev/null +++ b/apps/repl/app/config/environment.ts @@ -0,0 +1,39 @@ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +import { getGlobalConfig } from '@embroider/macros/src/addon/runtime'; + +const ENV = { + modulePrefix: 'limber', + environment: import.meta.env.DEV ? 'development' : 'production', + rootURL: '/', + locationType: 'history', + EmberENV: {}, + APP: {}, +} as { + environment: string; + modulePrefix: string; + podModulePrefix: string; + locationType: 'history' | 'hash' | 'none' | 'auto'; + rootURL: string; + EmberENV: Record; + APP: Record; + SERVICE_WORKER: boolean; +}; + +// ENV.APP.LOG_RESOLVER = true; +// ENV.APP.LOG_ACTIVE_GENERATION = true; +// ENV.APP.LOG_TRANSITIONS = true; +// ENV.APP.LOG_TRANSITIONS_INTERNAL = true; +// ENV.APP.LOG_VIEW_LOOKUPS = true; + +export default ENV; + +export function enterTestMode() { + ENV.locationType = 'none'; + ENV.APP.rootElement = '#ember-testing'; + ENV.APP.autoboot = false; + + const config = getGlobalConfig()['@embroider/macros']; + + if (config) config.isTesting = true; +} diff --git a/apps/repl/app/controllers/application.ts b/apps/repl/app/controllers/application.ts index 6bbe48eb8..08bd5ba55 100644 --- a/apps/repl/app/controllers/application.ts +++ b/apps/repl/app/controllers/application.ts @@ -40,6 +40,21 @@ export default class ApplicationController extends Controller { // - glimdown (default) // - gjs // - hbs + // - svelte + // - jsx|react + // - vue + // - mermaid 'format', + + // Load a file from the public directory + 'file', + + // Force the output to be rendered in to a shadow-dom + // or force it to not be rendered in to a shadow-dom if falsey value is passed + 'shadowdom', + + // Disable shiki highlighting on page load + // this is primarily an optimization for tests + 'nohighlight', ]; } diff --git a/apps/repl/app/helpers/qp.ts b/apps/repl/app/helpers/qp.ts deleted file mode 100644 index a335a5f3c..000000000 --- a/apps/repl/app/helpers/qp.ts +++ /dev/null @@ -1,19 +0,0 @@ -import Helper from '@ember/component/helper'; -import { service } from '@ember/service'; - -import type RouterService from '@ember/routing/router-service'; - -interface Signature { - Args: { - Positional: [string]; - }; - Return: string | undefined; -} - -export default class QP extends Helper { - @service declare router: RouterService; - - compute([name]: [string]): string { - return this.router.currentRoute?.queryParams?.[name] as string; - } -} diff --git a/apps/repl/app/languages.gts b/apps/repl/app/languages.gts new file mode 100644 index 000000000..0da0760e3 --- /dev/null +++ b/apps/repl/app/languages.gts @@ -0,0 +1,270 @@ +import { assert } from '@ember/debug'; + +import { default as FileReact } from '~icons/devicon/react?raw'; +import { default as FileSvelte } from '~icons/devicon/svelte?raw'; +import { default as FileVue } from '~icons/devicon/vuejs?raw'; +import { default as NestedMarkdown } from '~icons/mdi/language-markdown?raw'; +import { default as FileEmber } from '~icons/vscode-icons/file-type-ember?raw'; +import { default as FileGlimmer } from '~icons/vscode-icons/file-type-glimmer?raw'; +import { default as FileMarkdown } from '~icons/vscode-icons/file-type-markdown?raw'; +import { default as FileMermaid } from '~icons/vscode-icons/file-type-mermaid?raw'; + +import type { ComponentLike } from '@glint/template'; + +type Language = (typeof LANGUAGE)[number]; +/** + * Since the pair of format + flavor is always coupled, + * we're treating them as a single QP + */ +export type FormatQP = keyof typeof languages; + +/** + * This data is the source of truth, and is enriched afterwards + * + * { lang-key: lang-info } + * + * The compiler stettings for all these are configured in routes/application.ts + */ +const languages = { + gjs: { + name: 'Glimmer JS', + ext: 'gjs', + icon: , + }, + hbs: { + name: 'Ember Template', + ext: 'hbs', + icon: , + }, + vue: { + name: 'Vue', + ext: 'vue', + icon: , + }, + svelte: { + name: 'Svelte', + ext: 'sevlte', + icon: , + }, + 'jsx|react': { + name: 'JSX | React', + ext: 'jsx', + icon: , + }, + mermaid: { + name: 'Mermaid', + ext: 'yaml', + icon: , + }, + md: { + name: 'Markdown', + ext: 'md', + icon: , + }, + gmd: { + name: 'Glimdown', + ext: 'gmd', + icon: , + }, +} as const; + +const LANGUAGE = Object.entries(languages).reduce( + /** + * Add the key to each entry, which would make for easier iterating + * in some cases + */ + (result, [key, entry]) => { + const data = { + ...entry, + key, + formatQP: key, + }; + + result[key] = data; + + return result; + }, + {} as Record< + string, + { + name: string; + /** + * Filetype (usually) + */ + ext: string; + /** + * Includes the flavor + */ + formatQP: string; + key: string; + icon: ComponentLike<{ Element: null }>; + } + > +); + +const ALIASES = { + glimdown: 'gmd', + gdm: 'gmd', +} as Record; + +export const DEFAULT_FORMAT = 'glimdown'; + +const ALIAS_FORMATS = ['glimdown', 'gdm']; + +export const ALLOWED_FORMATS = [...ALIAS_FORMATS, ...Object.keys(languages)] as const; + +export const ALLOWED_FLAVORS = { + jsx: ['react'], +} as Record; + +export type Format = (typeof ALLOWED_FORMATS)[number]; + +function key(format: string, flavor: undefined | string) { + const lang = flavor ? `${format}|${flavor}` : format; + + return lang; +} + +export function infoFor(format: string, flavor?: undefined | string) { + const lang = flavor ? `${format}|${flavor}` : format; + + let info = LANGUAGE[key(format, flavor)]; + + // We have to do an alias check for all the prior former styles of formats + if (!info && ALIASES[format]) { + info = LANGUAGE[key(ALIASES[format], flavor)]; + } + + assert(`Could not find info for ${lang}${flavor ? ` and ${flavor}` : ''}`, info); + + return info; +} + +export function iconFor(format: string, flavor: undefined | string) { + return infoFor(format, flavor).icon; +} + +export function nameFor(format: string, flavor: undefined | string) { + return infoFor(format, flavor).name; +} + +export function isAllowedFormat(x?: string | null): x is (typeof ALLOWED_FORMATS)[number] { + return Boolean(x && ALLOWED_FORMATS.includes(x)); +} + +function isAllowedFlavor(format: string, flavor: string) { + return (ALLOWED_FLAVORS[format] ?? []).includes(flavor); +} + +export function flavorFrom(format: string | undefined | null, flavor?: string | null) { + if (!format) return; + if (!flavor) return; + + if (isAllowedFlavor(format, flavor)) { + return flavor; + } + + return; +} + +export function formatQPFrom(x: string | undefined | null): FormatQP { + // Historical Compat + if (x === 'glimdown') return 'gmd'; + if (x === 'gdm') return 'gmd'; + + assert(`Expected formatQP to be set`, x); + assert( + `Expected ${x} to be one of ${Object.keys(languages).join(', ')}`, + Object.keys(languages).includes(x) + ); + + return x as FormatQP; +} + +export function formatFrom(x: string | undefined | null): FormatQP { + if (isAllowedFormat(x)) { + return x as FormatQP; + } + + return 'gmd'; +} + +class Usage { + #ownKey = `repl-language-usage`; + + track(formatQP: FormatQP) { + const data = this.read(); + + data[formatQP] ||= 0; + data[formatQP]++; + + this.#set(data); + } + + read(): Record { + const raw = localStorage.getItem(this.#ownKey); + + if (!raw) return {}; + + try { + const parsed = JSON.parse(raw); + + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed; + } + + return {}; + } catch (e) { + console.debug('Malformed usage data in localStorage'); + console.debug(e); + + return {}; + } + } + + top2() { + const data = this.read(); + const sorted = Object.entries(data) + .sort((a, b) => b[1] - a[1]) + .map((a) => { + return LANGUAGE[a[0]]; + }) as Language[]; + + const top = sorted.slice(0, 2); + + const result = this.#withDefaultLangs(top); + + return result; + } + + #withDefaultLangs(langs: Language[]): Language[] { + const result = new Set([...langs, LANGUAGE.gjs!, LANGUAGE.gmd!]); + + return [...result.values()].slice(0, 2); + } + + #set(data: Record) { + localStorage.setItem(this.#ownKey, JSON.stringify(data)); + } +} + +export const usage = new Usage(); diff --git a/apps/repl/app/modifiers/-utils/highlighting.ts b/apps/repl/app/modifiers/-utils/highlighting.ts index 8d5887818..92a6dec7d 100644 --- a/apps/repl/app/modifiers/-utils/highlighting.ts +++ b/apps/repl/app/modifiers/-utils/highlighting.ts @@ -1,46 +1,92 @@ -import type { DOMPurify } from 'dompurify'; -import type { HLJSApi } from 'highlight.js'; +import type { HighlighterGeneric } from 'shiki'; -let HIGHLIGHT: HLJSApi; +let HIGHLIGHT: HighlighterGeneric; +let promise: Promise>; + +export async function getHighlighter(): Promise> { + if (promise) { + await promise; + } -export async function getHighlighter(): Promise { if (HIGHLIGHT) return HIGHLIGHT; - /** - * highlight.js is 282kb in total, - * since we now use hljs on initial page load, eagerly, we want to load - * as little as possible - */ - const [hljs, glimmer, javascript, typescript, markdown, css] = await Promise.all([ - import('highlight.js/lib/core'), - import('highlightjs-glimmer'), - import('highlight.js/lib/languages/javascript'), - import('highlight.js/lib/languages/typescript'), - import('highlight.js/lib/languages/markdown'), - import('highlight.js/lib/languages/css'), - ]); - - HIGHLIGHT = hljs.default; - HIGHLIGHT.registerLanguage('javascript', javascript.default); - HIGHLIGHT.registerLanguage('typescript', typescript.default); - HIGHLIGHT.registerLanguage('markdown', markdown.default); - HIGHLIGHT.registerLanguage('css', css.default); - - glimmer.setup(HIGHLIGHT); - - HIGHLIGHT.registerAliases('gjs', { languageName: 'glimmer-javascript' }); - HIGHLIGHT.registerAliases('gts', { languageName: 'glimmer-javascript' }); - HIGHLIGHT.registerAliases('glimdown', { languageName: 'markdown' }); - - return HIGHLIGHT; -} + const [{ createHighlighterCore }, { createOnigurumaEngine }, wasm, markdown, dark, oneDarkPro] = + await Promise.all([ + import('shiki/core'), + import('shiki/engine/oniguruma'), + import('shiki/wasm'), + import('shiki/langs/markdown.mjs'), + import('shiki/themes/github-dark.mjs'), + import('shiki/themes/one-dark-pro.mjs'), + ]); + + promise = createHighlighterCore({ + themes: [ + { + ...dark.default, + colors: { + ...dark.default.colors, + 'editor.background': 'var(--code-bg)', + }, + }, + { + ...oneDarkPro.default, + colors: { + ...oneDarkPro.default.colors, + 'editor.background': 'var(--code-bg)', + }, + }, + // import('shiki/themes/github-light.mjs'), + ], + langs: [ + import('shiki/langs/javascript.mjs'), + import('shiki/langs/css.mjs'), + import('shiki/langs/html.mjs'), + import('shiki/langs/glimmer-js.mjs'), + // Soon? + // import('shiki/langs/typescript.mjs'), + // import('shiki/langs/glimmer-ts.mjs'), + import('shiki/langs/handlebars.mjs'), + import('shiki/langs/jsonc.mjs'), + import('shiki/langs/svelte.mjs'), + { + // This *does* have embeddedLanguagesLazy + // Just not embeddedLanguages -let PURIFY: DOMPurify; + ...markdown.default[0]!, + embeddedLangs: [ + 'javascript', + 'css', + 'html', + 'glimmer-js', + // 'glimmer-ts', + // 'typescript', + 'handlebars', + 'jsonc', + 'svelte', + 'vue', + 'jsx', + 'mermaid', + ], + }, + import('shiki/langs/vue.mjs'), + import('shiki/langs/jsx.mjs'), + import('shiki/langs/mermaid.mjs'), + ], + langAlias: { + gjs: 'glimmer-js', + gts: 'glimmer-ts', + glimdown: 'markdown', + gmd: 'markdown', + gdm: 'markdown', + json: 'jsonc', + }, + engine: createOnigurumaEngine(() => wasm), + }); -export async function getPurifier() { - if (PURIFY) return PURIFY; + const highlighter = await promise; - PURIFY = (await import('dompurify')).default; + HIGHLIGHT = highlighter; - return PURIFY; + return highlighter; } diff --git a/apps/repl/app/modifiers/highlight-code-blocks.ts b/apps/repl/app/modifiers/highlight-code-blocks.ts deleted file mode 100644 index ec7cd6ad1..000000000 --- a/apps/repl/app/modifiers/highlight-code-blocks.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { modifier } from 'ember-modifier'; - -import { getHighlighter } from './-utils/highlighting'; - -export default modifier((element: HTMLElement, [_]: unknown[]) => { - if (!_) { - console.warn(`No argument was passed to {{highlight-code-blocks}}. Updates won't be detected`); - } - - (async () => { - const elements = element.querySelectorAll('pre > code'); - - for (const element of elements) { - const hljs = await getHighlighter(); - - hljs.highlightElement(element as HTMLElement); - } - })(); -}); diff --git a/apps/repl/app/modifiers/highlighted.ts b/apps/repl/app/modifiers/highlighted.ts index 62186af0f..05d38c74f 100644 --- a/apps/repl/app/modifiers/highlighted.ts +++ b/apps/repl/app/modifiers/highlighted.ts @@ -4,7 +4,9 @@ import { guidFor } from '@ember/object/internals'; import { modifier } from 'ember-modifier'; -import { getHighlighter, getPurifier } from './-utils/highlighting'; +import { isAllowedFormat } from '#app/languages.gts'; + +import { getHighlighter } from './-utils/highlighting'; interface Signature { Element: HTMLPreElement; @@ -21,7 +23,7 @@ export default modifier((element: Element, [code]) => { element.setAttribute('id', guid); (async () => { - const [hljs, purify] = await Promise.all([getHighlighter(), getPurifier()]); + const hljs = await getHighlighter(); // because the above is async, it's possible that the element // has been removed from the DOM @@ -29,10 +31,6 @@ export default modifier((element: Element, [code]) => { return; } - const target = element.querySelector('code'); - - if (!target) return; - if (DEBUG) { warn(`Cannot highlight code with undefined/null code`, Boolean(code), { id: 'limber.modifiers.highlighted', @@ -45,9 +43,22 @@ export default modifier((element: Element, [code]) => { } } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { value } = hljs.highlight(code, { language: target.classList[0]! }); + let lang = element.getAttribute('data-format') ?? element.classList[0]!; + + lang = lang.replace('language-', ''); + + if (lang === 'glimdown') { + lang = 'markdown'; + } + + if (!isAllowedFormat(lang)) { + return; + } + + lang = lang.split('|')[0]!; + + const html = hljs.codeToHtml(code, { lang, theme: 'github-dark' }); - target.innerHTML = purify.sanitize(value); + element.innerHTML = html; })(); }); diff --git a/apps/repl/app/registry.ts b/apps/repl/app/registry.ts index f0515de64..f996f3001 100644 --- a/apps/repl/app/registry.ts +++ b/apps/repl/app/registry.ts @@ -1,5 +1,3 @@ -import compatModules from '@embroider/virtual/compat-modules'; - import PageTitleService from 'ember-page-title/services/page-title'; // Can't import this until it's a v2 addon (without compat support, that is) @@ -22,16 +20,36 @@ function formatAsResolverEntries(imports: Record) { * - Services can be referenced via import paths (rather than strings) * - we design a new routing system */ -const resolverRegistry = { - ...formatAsResolverEntries(import.meta.glob('./templates/**/*.{gjs,gts,js,ts}', { eager: true })), +const autoRegistry = { ...formatAsResolverEntries(import.meta.glob('./services/**/*.{js,ts}', { eager: true })), ...formatAsResolverEntries(import.meta.glob('./routes/**/*.{js,ts}', { eager: true })), [`${appName}/router`]: Router, }; +import ApplicationController from './controllers/application.ts'; +import ApplicationTemplate from './templates/application.gts'; +import EditTemplate from './templates/edit.gts'; +import OutputTemplate from './templates/output.gts'; + export const registry = { - ...compatModules, - // [`${appName}/services/resize-observer`]: ResizeService, + // ///////////////// + // To Eliminate + // ///////////////// + + // Used by ember-container-query + [`${appName}/services/resize-observer`]: await import( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + 'ember-resize-observer-service/addon/services/resize-observer' + ), + + // ///////////////// + // To keep + // ///////////////// [`${appName}/services/page-title`]: PageTitleService, - ...resolverRegistry, + ...autoRegistry, + [`${appName}/controllers/application`]: ApplicationController, + [`${appName}/templates/application`]: ApplicationTemplate, + [`${appName}/templates/edit`]: EditTemplate, + [`${appName}/templates/output`]: OutputTemplate, }; diff --git a/apps/repl/app/router.ts b/apps/repl/app/router.ts index b8a56347e..61b7e7d51 100644 --- a/apps/repl/app/router.ts +++ b/apps/repl/app/router.ts @@ -1,7 +1,10 @@ import EmberRouter from '@embroider/router'; -import config from 'limber/config/environment'; +import { properLinks } from 'ember-primitives/proper-links'; +import config from '#config'; + +@properLinks export default class Router extends EmberRouter { location = config.locationType; rootURL = config.rootURL; diff --git a/apps/repl/app/routes/application.ts b/apps/repl/app/routes/application.ts index fb482c211..f0a9acc5e 100644 --- a/apps/repl/app/routes/application.ts +++ b/apps/repl/app/routes/application.ts @@ -1,17 +1,135 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import { getOwner } from '@ember/owner'; import Route from '@ember/routing/route'; +import { waitForPromise } from '@ember/test-waiters'; +import rehypeShikiFromHighlighter from '@shikijs/rehype/core'; +import Shadowed from 'ember-primitives/components/shadowed'; import { setupTabster } from 'ember-primitives/tabster'; +import { getCompiler, setupCompiler } from 'ember-repl'; +import { cell } from 'ember-resources'; + +import { getHighlighter } from '#app/modifiers/-utils/highlighting.ts'; +import CopyMenu from '#components/copy-menu.gts'; + +import { importMap } from './import-map.ts'; + +import type Owner from '@ember/owner'; const map = new WeakSet(); export default class ApplicationRoute extends Route { - // The router is littered with bugs.. so here we only let the model hook run once per instance - model() { + constructor(owner: Owner) { + super(owner); + if (!map.has(this)) { setupTabster(this); map.add(this); } + /** + * This is for private debugging. + */ + (globalThis as any)['REPL'] = { + state: { + get editor() { + return owner.lookup('service:editor'); + }, + get compiler() { + return getCompiler(owner); + }, + }, + owner: getOwner(this), + }; + + this.#promise = waitForPromise(this.#setup()); + } + + #promise: Promise | undefined; + + async #setup() { + const highlighter = await getHighlighter(); + + setupCompiler(this, { + options: { + gmd: { + scope: { + CopyMenu, + Shadowed, + }, + rehypePlugins: [ + [ + rehypeShikiFromHighlighter, + highlighter, + { + theme: 'github-dark', + }, + ], + ], + }, + md: { + rehypePlugins: [ + [ + rehypeShikiFromHighlighter, + highlighter, + { + theme: 'github-dark', + }, + ], + ], + }, + }, + /** + * Anything not specified here comes from NPM via + * ember-repl + */ + modules: { + // Ember Libraries Bundled with this REPL + 'ember-deep-tracked': () => import('ember-deep-tracked'), + 'ember-modifier': () => import('ember-modifier'), + 'ember-resources': () => import('ember-resources'), + 'tracked-built-ins': () => import('tracked-built-ins'), + 'ember-repl': () => import('ember-repl'), + // @ts-expect-error + 'ember-focus-trap': () => import('ember-focus-trap'), + // @ts-expect-error + 'tracked-toolbox': () => import('tracked-toolbox'), + + // Components from this app + // Used in demos + 'limber-ui': () => import('limber-ui'), + 'limber/components/limber/header': () => import('#edit/header.gts'), + 'limber/components/limber/menu': () => import('#components/menu.gts'), + 'limber/components/menu': () => import('#components/menu.gts'), + + // non-ember libraries + xstate: () => import('xstate'), + + // Polyfills for old behavior + // We still want old links to work + // aka Legacy things that don't exist anymore + 'limber/helpers/state': + async () => + (...args: unknown[]) => { + const c = cell(...args); + + return { + ...c, + + // @ts-expect-error + increment: () => c.current++, + get value() { + return c.current; + }, + }; + }, + ...importMap, + }, + }); + } + + async model() { + await this.#promise; document.querySelector('#initial-loader')?.remove(); } } diff --git a/apps/repl/app/routes/edit.ts b/apps/repl/app/routes/edit.ts index fa0144e32..a19acbfb5 100644 --- a/apps/repl/app/routes/edit.ts +++ b/apps/repl/app/routes/edit.ts @@ -1,9 +1,10 @@ import Route from '@ember/routing/route'; import { service } from '@ember/service'; +import { formatQPFrom } from '#app/languages.gts'; + import { DEFAULT_SNIPPET } from 'limber/snippets'; import { getStoredDocument } from 'limber/utils/editor-text'; -import { formatFrom } from 'limber/utils/messaging'; import type RouterService from '@ember/routing/router-service'; import type Transition from '@ember/routing/transition'; @@ -35,6 +36,20 @@ export default class EditRoute extends Route { const hasCode = Boolean(qps.t || qps.c); const hasFormat = qps.format !== undefined; + const hasFileReference = Boolean(qps.file); + + if (hasFileReference && hasFormat) { + transition.abort(); + + const format = formatQPFrom(qps.format as string); + const response = await fetch(qps.file as string); + const text = await response.text(); + + this.editor.fileURIComponent.set(text, format); + await this.editor.fileURIComponent.flush(); + + return; + } if (!hasCode) { /** @@ -47,7 +62,8 @@ export default class EditRoute extends Route { if (format && doc) { console.info(`Found format and document in localStorage. Using those.`); transition.abort(); - this.editor.fileURIComponent.set(doc, formatFrom(format)); + this.editor.fileURIComponent.set(doc, formatQPFrom(format)); + await this.editor.fileURIComponent.flush(); return; } @@ -58,12 +74,18 @@ export default class EditRoute extends Route { ); transition.abort(); - this.editor.fileURIComponent.set(DEFAULT_SNIPPET, 'glimdown'); - } else if (!hasFormat) { + this.editor.fileURIComponent.set(DEFAULT_SNIPPET, 'gmd'); + await this.editor.fileURIComponent.flush(); + + return; + } + + if (!hasFormat) { console.warn('URL contained no format SearchParam. Assuming glimdown'); transition.abort(); - this.editor.fileURIComponent.forceFormat('glimdown'); + this.editor.fileURIComponent.forceFormat('gmd'); + await this.editor.fileURIComponent.flush(); } // By the time execution gets here, we'll either: diff --git a/apps/repl/app/routes/import-map.ts b/apps/repl/app/routes/import-map.ts new file mode 100644 index 000000000..a3752c865 --- /dev/null +++ b/apps/repl/app/routes/import-map.ts @@ -0,0 +1,122 @@ +import { ExternalLink } from 'limber-ui'; + +export const importMap = {}; + +function defineWithWarning( + obj: object | (() => unknown), + { name, original, replacement }: { name?: string; original: string; replacement?: string } +) { + Object.defineProperty(importMap, original, { + get() { + const suggestion = replacement + ? `Please use ${replacement} going forward.` + : `There is not a direct replacement, please consult the docs for the library you're trying to use.`; + + if (name) { + console.warn( + `${name} is no longer located at ${original} and has been aliased for you. ${suggestion}` + ); + } else { + console.warn( + `The import you are using at ${original} no longerg exists and has been aliased for you. ${suggestion}` + ); + } + + if (typeof obj === 'function') { + return obj(); + } + + return obj; + }, + }); +} + +/** + * These paths are for backcompat + * Since code is maintained in URLs, + * we can't upgrade any of it. + * + * We could probably log a deprecation message on these paths + */ +defineWithWarning(ExternalLink, { + name: '', + original: 'limber/components/external-link', + replacement: 'limber-ui', +}); +defineWithWarning( + async () => { + const ePrimitives = await import('ember-primitives'); + + return { default: ePrimitives.Shadowed, Shadowed: ePrimitives.Shadowed }; + }, + { name: '', original: 'limber/components/shadowed', replacement: 'ember-primitives' } +); +defineWithWarning(() => import('#components/menu.gts'), { + original: 'limber/components/limber/menu', + replacement: 'limber/components/menu', +}); +defineWithWarning(() => import('ember-resources'), { + original: 'ember-resources/core', + replacement: 'ember-resources', +}); +defineWithWarning(() => import('reactiveweb/link'), { + name: 'link', + original: 'ember-resources/link', + replacement: 'reactiveweb/link', +}); +defineWithWarning(() => import('reactiveweb/resource/service'), { + name: 'service', + original: 'ember-resources/service', + replacement: 'reactiveweb/resource/service', +}); +defineWithWarning(() => import('reactiveweb/resource/modifier'), { + name: 'modifier', + original: 'ember-resources/modifier', + replacement: 'reactiveweb/resource/modifier', +}); +defineWithWarning(() => import('reactiveweb/map'), { + name: 'map', + original: 'ember-resources/util/map', + replacement: 'reactiveweb/map', +}); +defineWithWarning(() => import('reactiveweb/debounce'), { + name: 'debounce', + original: 'ember-resources/util/debounce', + replacement: 'reactiveweb/debounce', +}); +defineWithWarning(() => import('reactiveweb/keep-latest'), { + name: 'keepLatest', + original: 'ember-resources/util/keep-latest', + replacement: 'reactiveweb/keep-latest', +}); +defineWithWarning(() => import('reactiveweb/function'), { + name: 'function', + original: 'ember-resources/util/function', + replacement: 'reactiveweb/function', +}); +defineWithWarning(() => import('reactiveweb/fps'), { + name: 'FrameRate or UpdateFrequency', + original: 'ember-resources/util/fps', + replacement: 'reactiveweb/fps', +}); +defineWithWarning(() => import('reactiveweb/remote-data'), { + name: 'RemoteData', + original: 'ember-resources/util/remote-data', + replacement: 'reactiveweb/remote-data', +}); +defineWithWarning( + async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const module = await import('ember-focus-trap'); + + return { + default: module.focusTrap, + }; + }, + { + name: 'focusTrap', + original: 'ember-focus-trap/modifiers/focus-trap', + replacement: 'ember-focus-trap', + } +); diff --git a/apps/repl/app/services/editor.ts b/apps/repl/app/services/editor.ts index 83400acb8..de9a83ff9 100644 --- a/apps/repl/app/services/editor.ts +++ b/apps/repl/app/services/editor.ts @@ -1,37 +1,45 @@ import { tracked } from '@glimmer/tracking'; -import { action } from '@ember/object'; import Service, { service } from '@ember/service'; import { link } from 'reactiveweb/link'; import { FileURIComponent } from 'limber/utils/editor-text'; +import type { DemoEntry } from '../snippets'; import type RouterService from '@ember/routing/router-service'; -import type { Format } from 'limber/utils/messaging'; +import type { FormatQP } from '#app/languages.gts'; export default class EditorService extends Service { @service declare router: RouterService; - @tracked isCompiling = false; - @tracked error?: string; - @tracked errorLine?: number; @tracked scrollbarWidth = 0; - @link(FileURIComponent) declare fileURIComponent: FileURIComponent; + #fileURIComponent: FileURIComponent | undefined; + get fileURIComponent() { + if (this.#fileURIComponent) return this.#fileURIComponent; + // eslint-disable-next-line ember/no-side-effects + this.#fileURIComponent = new FileURIComponent(); + link(this.#fileURIComponent, this); - @action - updateText(text: string) { - this.fileURIComponent.queue(text, this.format); + return this.#fileURIComponent; } + updateText = (text: string) => { + this.fileURIComponent.queue(text); + }; + get text() { return this.fileURIComponent.decoded; } - get format() { + get format(): FormatQP { return this.fileURIComponent.format; } + get nohighlight() { + return (this.router.currentRoute?.queryParams ?? {}).nohighlight; + } + /** * This function is set by a modifier, * which means the timing of its existence is dependent on @@ -43,13 +51,13 @@ export default class EditorService extends Service { * exists and _then_ finish calling update demo. * */ - #editorSwapText?: (text: string, format: Format) => void; + #editorSwapText?: (text: string, format: FormatQP) => void; #pendingUpdate?: () => void; - get _editorSwapText() { + get setCodemirrorState() { return this.#editorSwapText; } - set _editorSwapText(value) { + set setCodemirrorState(value) { this.#editorSwapText = value; if (this.#pendingUpdate) { @@ -57,20 +65,21 @@ export default class EditorService extends Service { } } - @action - updateDemo(text: string, format: Format) { - if (!this._editorSwapText) { - this.#pendingUpdate = () => this.updateDemo(text, format); + updateDemo = (text: string, demo: DemoEntry) => { + const { format } = demo; + + if (!this.setCodemirrorState) { + this.#pendingUpdate = () => this.updateDemo(text, demo); return; } // Update ourselves - this.fileURIComponent.set(text, format); + this.fileURIComponent.set(text, format, demo && 'qps' in demo ? demo.qps : {}); // Update the editor - this._editorSwapText?.(text, format); - } + this.setCodemirrorState?.(text, format); + }; } // DO NOT DELETE: this is how TypeScript knows how to look up your services. diff --git a/apps/repl/app/services/status.ts b/apps/repl/app/services/status.ts new file mode 100644 index 000000000..20acefafb --- /dev/null +++ b/apps/repl/app/services/status.ts @@ -0,0 +1,24 @@ +import { cached, tracked } from '@glimmer/tracking'; +import Service from '@ember/service'; + +import { getCompiler } from 'ember-repl'; + +export default class StatusService extends Service { + @cached + get compiler() { + return getCompiler(this); + } + + get last() { + return this.compiler.lastInfo?.message; + } + + get error() { + return this.compiler.lastError?.message; + } + + @tracked showError = true; + + hideError = () => (this.showError = false); + newError = () => (this.showError = true); +} diff --git a/apps/repl/app/snippets.ts b/apps/repl/app/snippets.ts index fa38bc419..6f659a0cf 100644 --- a/apps/repl/app/snippets.ts +++ b/apps/repl/app/snippets.ts @@ -1,3 +1,5 @@ +import { assert } from '@ember/debug'; + export const DEFAULT_GJS = `// Welcome! const who = 'world'; @@ -21,9 +23,7 @@ if you're interested in a tutorial, check out `; -export const DEFAULT_SNIPPET = `# Limber Editor - -**glimdown** // _Ember or Glimmer rendered with markdown_ +export const DEFAULT_SNIPPET = `# Welcome!
+ +[svelte-demo]: /?format=svelte&file=/samples/svelte-demo.svelte +[vue-demo]: /?format=vue&file=/samples/vue-demo.vue +[jsx-react-demo]: /?format=jsx|react&file=/samples/jsx-react-demo.jsx&shadowdom=false +[gjs-ember-demo]: /?format=gjs&file=/samples/gjs-demo.gjs +[hbs-ember-demo]: /?format=hbs|ember&file=/samples/hbs-demo.hbs +[mermaid-demo]: /?format=mermaid&file=/samples/mermaid-demo.mermaid +[md-demo]: /?format=md&file=/samples/all.md +[gmd-demo]: / + +[docs-svelte]: https://svelte.dev/ +[docs-vue]: https://vuejs.org/ +[docs-react]: https://react.dev/ +[docs-ember]: https://emberjs.com/ +[docs-mermaid]: https://mermaid.js.org/ +[docs-markdown]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax + +## Glimdown + +Some globally available (predefined) functions can be invoked in glimdown, for example, using \`array\` and \`hash\`: List of links:
    @@ -46,23 +80,75 @@ List of links: {{/each}}
+This list is user-configurable via: +\`\`\`js +import { setupCompiler } from 'ember-repl'; + +// ... + +setupCompiler(this, { + options: { + gmd: { + scope: { + // your scope additions here + }, + }, + }, +); +\`\`\` + [1]: https://github.com/glimmerjs/glimmer-experimental/tree/master/packages/examples/playground [2]: https://github.com/ember-template-imports/ember-template-imports -[3]: https://github.com/NullVoxPopuli/limber/issues/14`; +[3]: https://github.com/NullVoxPopuli/limber/issues/14 + +`; +/** + * NOTE: label must be unique + */ export const ALL = [ - { label: 'Welcome', snippet: DEFAULT_SNIPPET }, - { label: 'With inline Javascript', path: '/samples/live-js.md' }, - { label: 'With inline Templates', path: '/samples/live-hbs.md' }, - { label: 'Styleguide Demo', path: '/samples/styleguide-demo.md' }, - { label: 'Build your own REPL', path: '/samples/repl.md' }, - { label: 'Menu with focus trap', path: '/samples/menu-with-focus-trap.md' }, - { label: 'Forms', path: '/samples/forms/intro.md' }, - { label: 'RemoteData', path: '/samples/remote-data.md' }, + { format: 'gmd', label: 'Welcome', snippet: DEFAULT_SNIPPET }, + { format: 'md', label: 'All Frameworks in Markdown', path: '/samples/all.md' }, + { format: 'gjs', label: 'Ember GJS', path: '/samples/gjs-demo.gjs' }, + { format: 'svelte', label: 'Svelte', path: '/samples/svelte-demo.svelte' }, + // Yaml + { format: 'mermaid', label: 'Mermaid', path: '/samples/mermaid-demo.mermaid' }, + { format: 'vue', label: 'Vue', path: '/samples/vue-demo.vue' }, + { + format: 'jsx|react', + label: 'React JSX', + path: '/samples/jsx-react-demo.jsx', + qps: { shadowdom: '0' }, + }, + { format: 'hbs', label: 'Ember HBS', path: '/samples/hbs-demo.hbs' }, + { format: 'md', label: 'With inline Javascript', path: '/samples/live-js.md' }, + { format: 'gmd', label: 'With inline Templates', path: '/samples/live-hbs.md' }, + { + format: 'md', + label: 'Styleguide Demo', + path: '/samples/styleguide-demo.md', + qps: { shadowdom: '0' }, + }, + { format: 'md', label: 'Build your own REPL', path: '/samples/repl.md' }, + { + format: 'md', + label: 'Menu with focus trap', + path: '/samples/menu-with-focus-trap.md', + qps: { shadowdom: '0' }, + }, + { format: 'md', label: 'Forms', path: '/samples/forms/intro.md' }, + { format: 'md', label: 'RemoteData', path: '/samples/remote-data.md' }, ] as const; +export type DemoEntry = (typeof ALL)[number]; + export const NAMES = ALL.map((demo) => demo.label); +assert( + `Expected every label of the list of Demo snippets to be unique`, + new Set(NAMES).size === ALL.length +); + export const LOADED = new Set([DEFAULT_SNIPPET]); export async function getFromLabel(label: string): Promise { @@ -70,17 +156,21 @@ export async function getFromLabel(label: string): Promise { if (!entry) return DEFAULT_SNIPPET; - if ('snippet' in entry) { + if ('snippet' in entry && typeof entry.snippet === 'string') { return entry.snippet; } - const path = entry.path; - const response = await fetch(path); - const text = await response.text(); + if ('path' in entry) { + const path = entry.path; + const response = await fetch(path); + const text = await response.text(); - LOADED.add(text); + LOADED.add(text); + + return text; + } - return text; + throw new Error(`Unhandled snippet control flow. Please open an issue`); } export function defaultSnippetForFormat(format: string) { diff --git a/apps/repl/app/styles/app.css b/apps/repl/app/styles/app.css index cf2b497f3..0571d0252 100644 --- a/apps/repl/app/styles/app.css +++ b/apps/repl/app/styles/app.css @@ -9,9 +9,19 @@ touch-action: manipulation; } -/** - * https://www.w3schools.com/howto/howto_css_loader.asp - */ +/* +* From +* https://fonts.googleapis.com/css2?family=Source+Code+Pro&display=swap +*/ +@font-face { + font-family: 'Source Code Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(https://fonts.gstatic.com/s/sourcecodepro/v30/HI_diYsKILxRpg3hIP6sJ7fM7PqPMcMnZFqUwX28DMyQtMlrTA.woff2) format('woff2'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + .loader { border: 4px solid var(--ember-faint-gray); @@ -27,33 +37,12 @@ margin-top: 0.5rem; } -.glimdown-render button { - color: white; +[data-repl-output] button { border-radius: 0.25rem; padding: 0.25rem 0.5rem; - background: var(--code-bg); border: 1px solid var(--horizon-border); } -.glimdown-render button:focus { - outline: 2px solid transparent; - outline-offset: 2px; - --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) - var(--tw-ring-offset-color); - --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(4px + var(--tw-ring-offset-width)) - var(--tw-ring-color); - box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); -} - -.glimdown-render button:focus-visible { - outline: 2px solid transparent; - outline-offset: 2px; -} - -.glimdown-render button:hover { - opacity: 0.9; -} - .gutter { background: var(--code-keyword); } @@ -68,3 +57,32 @@ dialog::backdrop { backdrop-filter: blur(1px); } +.menu__keyboard__help { + font-size: 0.65rem; + padding: 0.1rem 0.6rem 0.3rem 1rem; + background: white; + color: black; + z-index: -1; + border-bottom-left-radius: 0.25rem; + border-bottom-right-radius: 0.25rem; +} + +@media (pointer:coarse) { + .menu__keyboard__help { + display: none; + } +} + +pre.shiki { + padding: 1rem; + border-radius: 0.5rem; + box-shadow: inset 0px 1px 1px 1px black; +} + +.limber__editor-placeholder pre.shiki { + padding: 0; + padding-left: 1rem; + box-shadow: none; +} + + diff --git a/apps/repl/app/templates/application.gts b/apps/repl/app/templates/application.gts index 4b7558d01..35d4e7450 100644 --- a/apps/repl/app/templates/application.gts +++ b/apps/repl/app/templates/application.gts @@ -1,10 +1,7 @@ -import { PortalTargets } from 'ember-primitives'; -import Route from 'ember-route-template'; +import { PortalTargets } from 'ember-primitives/components/portal-targets'; -export default Route( - diff --git a/apps/repl/app/templates/edit.gts b/apps/repl/app/templates/edit.gts index 7b7de3049..332db2219 100644 --- a/apps/repl/app/templates/edit.gts +++ b/apps/repl/app/templates/edit.gts @@ -1,35 +1,58 @@ +import Component from '@glimmer/component'; +import { service } from '@ember/service'; + import { notInIframe } from 'ember-primitives/iframe'; -import Route from 'ember-route-template'; -import Editor from 'limber/components/limber/editor'; -import FrameOutput from 'limber/components/limber/frame-output'; -import Guest from 'limber/components/limber/guest'; -import Header from 'limber/components/limber/header'; -import Help from 'limber/components/limber/help'; -import Layout from 'limber/components/limber/layout'; +import Output from '#components/output.gts'; + +import { ExternalLink as Link } from 'limber-ui'; + +import Editor from './edit/editor/index.gts'; +import Guest from './edit/guest.gts'; +import Header from './edit/header'; +import Help from './edit/help.gts'; +import Layout from './edit/layout/index.gts'; + +import type RouterService from '@ember/routing/router-service'; + +class OpenOutput extends Component { + @service declare router: RouterService; + + get href() { + return this.router.currentURL?.replace('/edit?', '/output?'); + } -export default Route( -); +} + + diff --git a/apps/repl/app/components/limber/demo-select.gts b/apps/repl/app/templates/edit/demo-select.gts similarity index 59% rename from apps/repl/app/components/limber/demo-select.gts rename to apps/repl/app/templates/edit/demo-select.gts index 670252b97..d8dfb037f 100644 --- a/apps/repl/app/components/limber/demo-select.gts +++ b/apps/repl/app/templates/edit/demo-select.gts @@ -8,8 +8,10 @@ import { waitFor } from '@ember/test-waiters'; import FaIcon from '@fortawesome/ember-fontawesome/components/fa-icon'; import { faAngleRight, faAngleUp } from '@fortawesome/free-solid-svg-icons'; -import Menu from 'limber/components/limber/menu'; -import { getFromLabel, NAMES } from 'limber/snippets'; +import { infoFor } from '#app/languages.gts'; +import Menu from '#components/menu.gts'; + +import { ALL, type DemoEntry, getFromLabel } from 'limber/snippets'; import type RouterService from '@ember/routing/router-service'; import type EditorService from 'limber/services/editor'; @@ -20,17 +22,17 @@ export class DemoSelect extends Component { @action @waitFor - async select(demoName: string) { - const demo = await getFromLabel(demoName); + async select(demo: DemoEntry) { + const text = await getFromLabel(demo.label); - this.editor.updateDemo(demo, 'glimdown'); + this.editor.updateDemo(text, demo); } } diff --git a/apps/repl/app/components/limber/editor/-code-mirror.ts b/apps/repl/app/templates/edit/editor/-code-mirror.ts similarity index 73% rename from apps/repl/app/components/limber/editor/-code-mirror.ts rename to apps/repl/app/templates/edit/editor/-code-mirror.ts index 27fc2daa6..68930bd79 100644 --- a/apps/repl/app/components/limber/editor/-code-mirror.ts +++ b/apps/repl/app/templates/edit/editor/-code-mirror.ts @@ -1,13 +1,14 @@ import { assert } from '@ember/debug'; import { isDestroyed, isDestroying, registerDestructor } from '@ember/destroyable'; import { service } from '@ember/service'; +import { waitForPromise } from '@ember/test-waiters'; import Modifier from 'ember-modifier'; import type { EditorView } from '@codemirror/view'; import type RouterService from '@ember/routing/router-service'; +import type { FormatQP } from '#app/languages.gts'; import type EditorService from 'limber/services/editor'; -import type { Format } from 'limber/utils/messaging'; type Signature = { Element: HTMLDivElement; @@ -26,14 +27,14 @@ export default class CodeMirror extends Modifier { @service declare router: RouterService; modify(element: Element) { - this.setup(element); + waitForPromise(this.setup(element)); } // For deduping incidental changes to the *format* Signal // (Since auto-tracking is _by-reference_ for now) // // We only want to re-create the editor when the format changes value - previousFormat: Format | undefined; + previousFormat: FormatQP | undefined; setup = async (element: Element) => { const { format } = this.editor; @@ -61,16 +62,18 @@ export default class CodeMirror extends Modifier { element.innerHTML = ''; element.setAttribute('data-format', format); - const { view, setText } = CODEMIRROR(element, value, format, updateText); + const { view, setText } = await CODEMIRROR(element, value, format, updateText); + + if (isDestroyed(this) || isDestroying(this)) return; /** * This has to be defined on the service so that * the demo selector can also affect both the URL and the editor */ - this.editor._editorSwapText = (text, format) => { - element.setAttribute('data-format', format); + this.editor.setCodemirrorState = (text, formatQP) => { + element.setAttribute('data-formatQP', formatQP); - setText(text, format); // update the editor + waitForPromise(setText(text, format)); // update the editor }; const scrollable = document.querySelector('.cm-scroller'); @@ -88,18 +91,30 @@ let CODEMIRROR: | (( element: HTMLElement, value: string | null, - format: Format, + format: FormatQP, updateText: (text: string) => void - ) => { view: EditorView; setText: (text: string, format: Format) => void }); + ) => Promise<{ view: EditorView; setText: (text: string, format: FormatQP) => Promise }>); + + +let promise: Promise; /** * This is called from the state machine which manages loading state */ export async function setupCodeMirror() { - if (CODEMIRROR) return; + if (promise) await promise; + if (CODEMIRROR) return CODEMIRROR; // TypeScript doesn't have a way to type files in the public folder // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - CODEMIRROR = (await import('@nullvoxpopuli/limber-codemirror/preconfigured')).default; + promise = (async () => { + const module = await (import('@nullvoxpopuli/limber-codemirror/preconfigured')); + + return module.default + })(); + + CODEMIRROR = await promise; + + return CODEMIRROR; } diff --git a/apps/repl/app/components/limber/editor/index.gts b/apps/repl/app/templates/edit/editor/index.gts similarity index 82% rename from apps/repl/app/components/limber/editor/index.gts rename to apps/repl/app/templates/edit/editor/index.gts index 89c8e242c..0cdcec65a 100644 --- a/apps/repl/app/components/limber/editor/index.gts +++ b/apps/repl/app/templates/edit/editor/index.gts @@ -1,13 +1,16 @@ +import './styles.css'; + import { waitForPromise } from '@ember/test-waiters'; +import { Key } from 'ember-primitives/components/keys'; import { service } from 'ember-primitives/helpers/service'; import { resource, resourceFactory } from 'ember-resources'; import { TrackedObject } from 'tracked-built-ins'; -import codemirror, { setupCodeMirror } from './-code-mirror'; -import Loader from './loader'; -import { LoadingError } from './loading-error'; -import { Placeholder } from './placeholder'; +import codemirror, { setupCodeMirror } from './-code-mirror.ts'; +import Loader from './loader.gts'; +import { LoadingError } from './loading-error.gts'; +import { Placeholder } from './placeholder.gts'; import type { TOC } from '@ember/component/template-only'; @@ -62,7 +65,8 @@ export const Editor: TOC<{ {{#if state.isDone}} {{#let (service "editor") as |context|}} -
+
+
press esc to tab out
{{! template-lint-disable no-inline-styles }}
{{context.text}}
diff --git a/apps/repl/app/components/limber/editor/loader.gts b/apps/repl/app/templates/edit/editor/loader.gts similarity index 100% rename from apps/repl/app/components/limber/editor/loader.gts rename to apps/repl/app/templates/edit/editor/loader.gts diff --git a/apps/repl/app/components/limber/editor/loading-error.gts b/apps/repl/app/templates/edit/editor/loading-error.gts similarity index 100% rename from apps/repl/app/components/limber/editor/loading-error.gts rename to apps/repl/app/templates/edit/editor/loading-error.gts diff --git a/apps/repl/app/components/limber/editor/placeholder.gts b/apps/repl/app/templates/edit/editor/placeholder.gts similarity index 65% rename from apps/repl/app/components/limber/editor/placeholder.gts rename to apps/repl/app/templates/edit/editor/placeholder.gts index 473496730..9e167b05b 100644 --- a/apps/repl/app/components/limber/editor/placeholder.gts +++ b/apps/repl/app/templates/edit/editor/placeholder.gts @@ -1,6 +1,6 @@ import { service } from 'ember-primitives/helpers/service'; +import { qp } from 'ember-primitives/qp'; -import qp from 'limber/helpers/qp'; import highlighted from 'limber/modifiers/highlighted'; import type { TOC } from '@ember/component/template-only'; @@ -11,18 +11,15 @@ export const Placeholder: TOC<{ Element: HTMLPreElement; }> = ; diff --git a/apps/repl/app/templates/edit/editor/styles.css b/apps/repl/app/templates/edit/editor/styles.css new file mode 100644 index 000000000..82b494bb4 --- /dev/null +++ b/apps/repl/app/templates/edit/editor/styles.css @@ -0,0 +1,35 @@ +.limber__editor { + position: relative; + + .limber__editor__tab-help { + position: absolute; + bottom: 0.25rem; + right: 0; + opacity: 0; + pointer-events: none; + z-index: 1; + color: white; + font-size: 0.75rem; + padding: 0.125rem 1rem; + text-shadow: 0 1px 2px black; + kbd { + text-shadow: none; + } + } + + &:has(.cm-focused) { + .limber__editor__tab-help { + opacity: 1; + } + } +} + +@media (pointer:coarse) { + .limber__editor .limber__editor__tab-help { + /** + * Touch devices don't usually use keyboards + */ + display: none !important; + } +} + diff --git a/apps/repl/app/components/limber/layout/controls/format-buttons.gts b/apps/repl/app/templates/edit/format-buttons.gts similarity index 65% rename from apps/repl/app/components/limber/layout/controls/format-buttons.gts rename to apps/repl/app/templates/edit/format-buttons.gts index 384cfaec8..90cf42078 100644 --- a/apps/repl/app/components/limber/layout/controls/format-buttons.gts +++ b/apps/repl/app/templates/edit/format-buttons.gts @@ -5,17 +5,48 @@ import { service } from '@ember/service'; import { type ItemSignature, ToggleGroup } from 'ember-primitives/components/toggle-group'; +import { usage } from '#app/languages.gts'; + import { defaultSnippetForFormat } from 'limber/snippets'; import { getStoredDocumentForFormat } from 'limber/utils/editor-text'; +import { FormatMenu } from './format-menu.gts'; + import type { TOC } from '@ember/component/template-only'; import type RouterService from '@ember/routing/router-service'; import type { ComponentLike } from '@glint/template'; +import type { FormatQP } from '#app/languages.gts'; import type EditorService from 'limber/services/editor'; -import type { Format } from 'limber/utils/messaging'; + +const buttonClasses = ` +ring-ember-brand relative px-2 py-1 text-left drop-shadow-md transition duration-150 ease-in-out hover:drop-shadow-xl focus:rounded focus:outline-none focus:ring-4 focus-visible:rounded focus-visible:outline-none sm:text-sm +text-white +select-none +`; + +const top2 = usage.top2(); + +function toUpper(ext: string) { + return ext.toUpperCase(); +} export const FormatButtons: TOC = ; class Option extends Component<{ Element: HTMLButtonElement; Args: { item: ComponentLike; - value: Format; + value: string; description: string; }; Blocks: { default: [] }; @@ -56,7 +75,7 @@ class Option extends Component<{ @service declare router: RouterService; @service declare editor: EditorService; - active = (format: Format) => { + active = (format: string) => { return this.format === format ? 'bg-[#333] text-white' : 'bg-ember-black text-white'; }; @@ -64,15 +83,14 @@ class Option extends Component<{ * Because most of the formats are not cross-compatible with each other, * we'll want to also swap the document */ - switch = (format: Format): void => { - const stored = getStoredDocumentForFormat(format); + switch = (value: FormatQP): void => { + const stored = getStoredDocumentForFormat(value); - this.editor.fileURIComponent.set(stored ?? defaultSnippetForFormat(format), format); - // this.router.transitionTo({ queryParams: { format, } }); + this.editor.fileURIComponent.set(stored ?? defaultSnippetForFormat(value), value); }; - get format(): Format { - return this.router.currentRoute?.queryParams?.format as Format; + get format(): FormatQP { + return this.router.currentRoute?.queryParams?.format as FormatQP; }