From 7befcbba86e82b9bde1d9af682d568afd536253f Mon Sep 17 00:00:00 2001 From: Ryan Haskell-Glatz Date: Sat, 16 Jan 2021 13:04:32 -0600 Subject: [PATCH] generate route to href function --- .gitignore | 1 + src/cli/src/cli/build.ts | 3 +- src/cli/src/templates/routes.ts | 15 ++++--- src/cli/src/templates/utils.ts | 45 +++++++++++++++++++-- src/cli/tests/templates/utils.spec.ts | 57 ++++++++++++++++++++++++++- 5 files changed, 110 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 08a942c..4857910 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .DS_Store +.elm-spa elm-stuff node_modules dist diff --git a/src/cli/src/cli/build.ts b/src/cli/src/cli/build.ts index 974518e..587d21c 100644 --- a/src/cli/src/cli/build.ts +++ b/src/cli/src/cli/build.ts @@ -9,6 +9,7 @@ import MsgTemplate from '../templates/msg' import ParamsTemplate from '../templates/params' import * as Process from '../process' import { bold, underline, colors, reset, check, dim } from "../terminal" +import { isStaticPage } from "../templates/utils" export const build = (env : Environment) => () => createMissingDefaultFiles() @@ -58,7 +59,7 @@ const createMissingDefaultFiles = async () => { const scanForStaticPages = async (entries: PageEntry[]) : Promise => { const contents = await Promise.all(entries.map(e => File.read(e.filepath))) return contents - .map((content, i) => content.includes('exposing (page)') ? i : undefined) + .map((content, i) => isStaticPage(content) ? i : undefined) .filter(a => typeof a === 'number') .map((i : any) => entries[i].segments) } diff --git a/src/cli/src/templates/routes.ts b/src/cli/src/templates/routes.ts index 1ce55b8..460ca9d 100644 --- a/src/cli/src/templates/routes.ts +++ b/src/cli/src/templates/routes.ts @@ -1,10 +1,10 @@ -import { routeTypeDefinition, indent, routeParserList, paramsImports, Options } from "./utils" +import { routeTypeDefinition, indent, routeParserList, paramsImports, Options, routeToHref } from "./utils" export default (pages : string[][], _options : Options) : string => ` module Gen.Route exposing ( Route(..) , fromUrl - -- , toUrl + , toHref ) ${paramsImports(pages)} @@ -25,8 +25,13 @@ routes = ${indent(routeParserList(pages), 1)} --- toUrl : Route -> Url --- toUrl route = --- Debug.todo "Gen.Route.toUrl" +toHref : Route -> String +toHref route = + let + joinAsHref : List String -> String + joinAsHref segments = + "/" ++ String.join "/" segments + in +${indent(routeToHref(pages), 1)} `.trimLeft() \ No newline at end of file diff --git a/src/cli/src/templates/utils.ts b/src/cli/src/templates/utils.ts index 7a9a4e3..ce6834d 100644 --- a/src/cli/src/templates/utils.ts +++ b/src/cli/src/templates/utils.ts @@ -12,13 +12,18 @@ const isHomepage = (path: string[]) => const isNotFoundPage = (path: string[]) => path.join('') === config.reserved.notFound -// [ 'Users', 'Name_', 'Settings' ] => [ 'Name_' ] +// [ 'Users', 'Name_', 'Settings' ] => [ 'Name' ] const dynamicRouteSegments = (path : string[]) : string[] => isHomepage(path) || isNotFoundPage(path) ? [] - : path.filter(segment => segment.endsWith('_')) + : path.filter(isDynamicSegment) .map(segment => segment.substr(0, segment.length - 1)) +const isDynamicSegment = (segment : string) : boolean => + segment !== config.reserved.homepage + && segment !== config.reserved.notFound + && segment.endsWith('_') + // "AboutUs" => "aboutUs" const fromPascalToCamelCase = (str : string) : string => str[0].toLowerCase() + str.substring(1) @@ -127,6 +132,29 @@ export const routeTypeDefinition = (paths: string[][]) : string => export const routeParserList = (paths: string[][]) : string => multilineList(paths.map(routeParserMap)) +export const routeToHref = (paths: string[][]) : string => + caseExpression(paths, { + variable: 'route', + condition: (path) => + (dynamicRouteSegments(path).length === 0) + ? routeVariant(path) + : `${routeVariant(path)} params`, + result: (path) => `joinAsHref ${routeToHrefSegments(path)}` + }) + +export const routeToHrefSegments = (path: string[]) : string => { + const segments = path.filter(p => p !== config.reserved.homepage) + const hrefFragments = + segments.map(segment => + isDynamicSegment(segment) + ? `params.${fromPascalToCamelCase(segment.substring(0, segment.length - 1))}` + : `"${fromPascalToSlugCase(segment)}"` + ) + return hrefFragments.length === 0 + ? `[]` + : `[ ${hrefFragments.join(', ')} ]` +} + export const paramsImports = (paths: string[][]) : string => paths.map(path => `import Gen.Params.${path.join('.')}`).join('\n') @@ -193,7 +221,6 @@ const msgVariant = (path: string[]) : string => const msg = (path: string[]) : string => `Pages.${path.join('.')}.Msg` - export const pagesInitBody = (paths: string[][]) : string => indent(caseExpression(paths, { variable: 'route', @@ -240,4 +267,14 @@ const destructuredModel = (path: string[], options : Options) : string => const pageModelArguments = (path: string[], options : Options) : string => options.isStatic(path) ? `params ()` - : `params model` \ No newline at end of file + : `params model` + +// Used in place of sophisticated AST parsing +const exposes = (keyword: string) => (elmSourceCode: string): boolean => + new RegExp(`module\\s(\\S)+\\sexposing(\\s)+\\([^\\)]*${keyword}[^\\)]*\\)`, 'm').test(elmSourceCode) + +export const exposesModel = exposes('Model') +export const exposesMsg = exposes('Msg') + +export const isStaticPage = (sourceCode : string) : boolean => + !exposesModel(sourceCode) || !exposesMsg(sourceCode) \ No newline at end of file diff --git a/src/cli/tests/templates/utils.spec.ts b/src/cli/tests/templates/utils.spec.ts index b703dbf..495c659 100644 --- a/src/cli/tests/templates/utils.spec.ts +++ b/src/cli/tests/templates/utils.spec.ts @@ -143,4 +143,59 @@ type Route ] `.trim()) }) -}) \ No newline at end of file + + test.each([ + [ [ config.reserved.homepage ], `[]` ], + [ [ "AboutUs" ], `[ "about-us" ]` ], + [ [ "AboutUs", "Offices" ], `[ "about-us", "offices" ]` ], + [ [ "Posts" ], `[ "posts" ]` ], + [ [ "Posts", "Id_" ], `[ "posts", params.id ]` ], + [ [ "Users", "Name_", "Settings" ], `[ "users", params.name, "settings" ]` ], + [ [ "Users", "Name_", "Posts", "Id_" ], `[ "users", params.name, "posts", params.id ]` ], + ])(".routeVariant(%p)", (input, output) => { + expect(Utils.routeToHrefSegments(input)).toBe(output) + }) +}) + +describe.each([['Model'], ['Msg']]) + ('Utils.exposes%s', (name: string) => { + const fn = (Utils as any)[`exposes${name}`] as (val: string) => boolean + + test('fails for exposing all', () => + expect(fn(`module Layout exposing (..)`)).toBe(false) + ) + + test(`fails if missing keyword`, () => { + expect(fn(`module Layout exposing (OtherImport)`)).toBe(false) + expect(fn(`module Layout exposing + ( OtherImport + ) + `)).toBe(false) + }) + + test(`works with single-line exposing "${name}"`, () => { + expect(fn(`module Layout exposing (${name})`)).toBe(true) + expect(fn(`module Layout exposing (OtherImport, ${name})`)).toBe(true) + expect(fn(`module Layout exposing (${name}, OtherImport)`)).toBe(true) + }) + + test(`works with multi-line exposing "${name}"`, () => { + expect(fn(` + module Layout exposing + ( ${name} + ) + `)).toBe(true) + expect(fn(` + module Layout exposing + ( OtherImport + , ${name} + ) + `)).toBe(true) + expect(fn(` + module Layout exposing + ( ${name} + , OtherImport + ) + `)).toBe(true) + }) + }) \ No newline at end of file