├── .eslintrc.yml ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── pipeline.yml │ └── pr-auto-update.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── README.md ├── assets ├── Flatbread V 3 Export@1-1080x1080.jpg ├── Flatbread V 3 Export@1-1080x1080.png ├── flatbread logo v2 x4@1-1728x1080 centered header.png ├── flatbread logo v2 x4@1-1728x1080 centered.png ├── flatbread logo v2 x4@1-1728x1080.png ├── flatbread v1@1-1080x1080.png ├── flatbread v1@1-772x634.png ├── flatbread_v_2.spline └── flatbread_v_3.spline ├── ava.config.js ├── examples ├── content │ ├── README.md │ ├── markdown │ │ ├── authors │ │ │ ├── daes.md │ │ │ ├── eva.md │ │ │ ├── tony.md │ │ │ ├── ushi.md │ │ │ └── yoshi.md │ │ ├── deeply-nested │ │ │ └── test.md │ │ └── posts │ │ │ ├── anotha-one.md │ │ │ ├── art │ │ │ └── test.md │ │ │ ├── b.md │ │ │ ├── category with space │ │ │ └── test.md │ │ │ ├── code │ │ │ └── another-category-test.md │ │ │ ├── example-post.md │ │ │ └── soup.md │ └── yaml │ │ ├── authors │ │ ├── eva.yml │ │ └── me.yml │ │ └── posts │ │ ├── anotha-one.yml │ │ └── example-post.yml ├── nextjs │ ├── .eslintrc.json │ ├── .gitignore │ ├── README.md │ ├── content │ ├── flatbread.config.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── pages │ │ ├── _app.tsx │ │ ├── api │ │ │ └── hello.ts │ │ └── index.tsx │ ├── public │ │ ├── favicon.ico │ │ └── vercel.svg │ ├── styles │ │ ├── Home.module.css │ │ └── globals.css │ └── tsconfig.json └── sveltekit │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── content │ ├── flatbread.config.js │ ├── package.json │ ├── postcss.config.cjs │ ├── src │ ├── app.css │ ├── app.html │ ├── global.d.ts │ ├── lib │ │ └── components │ │ │ ├── Label.svelte │ │ │ └── Pane.svelte │ └── routes │ │ ├── __layout.svelte │ │ └── index.svelte │ ├── static │ ├── authorImages │ │ ├── eva.svg │ │ └── tony.svg │ ├── favicon.png │ └── g │ │ ├── eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.avif │ │ ├── eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.svg │ │ ├── eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.webp │ │ ├── tony.1255970.4b9b7277a1ba63096536c038ed848dc9.avif │ │ ├── tony.1255970.4b9b7277a1ba63096536c038ed848dc9.svg │ │ └── tony.1255970.4b9b7277a1ba63096536c038ed848dc9.webp │ ├── svelte.config.js │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── vite.config.ts ├── package.json ├── packages ├── config │ ├── README.md │ ├── package.json │ ├── src │ │ ├── errors.ts │ │ ├── filenames.ts │ │ ├── index.ts │ │ ├── load.ts │ │ └── validate.ts │ └── tsup.config.ts ├── core │ ├── README.md │ ├── package.json │ ├── src │ │ ├── cache │ │ │ └── cache.ts │ │ ├── errors.ts │ │ ├── generators │ │ │ ├── arguments.ts │ │ │ ├── generateCollection.ts │ │ │ └── schema.ts │ │ ├── index.ts │ │ ├── providers │ │ │ ├── base.ts │ │ │ └── test │ │ │ │ ├── base.test.ts │ │ │ │ └── snapshots │ │ │ │ ├── base.test.ts.md │ │ │ │ └── base.test.ts.snap │ │ ├── resolvers │ │ │ └── arguments.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── arrayUtils.ts │ │ │ ├── camelCase.ts │ │ │ ├── deepEntries.ts │ │ │ ├── fieldOverrides.ts │ │ │ ├── initializeConfig.ts │ │ │ ├── map.ts │ │ │ ├── objectUtils.ts │ │ │ ├── outdent.ts │ │ │ ├── reduceBooleans.ts │ │ │ ├── sift.ts │ │ │ ├── stringUtils.ts │ │ │ ├── tests │ │ │ ├── fieldOverrides.test.ts │ │ │ ├── outdent.test.ts │ │ │ ├── sift.test.ts │ │ │ ├── snapshots │ │ │ │ ├── fieldOverrides.test.ts.md │ │ │ │ ├── fieldOverrides.test.ts.snap │ │ │ │ ├── transformKeys.test.ts.md │ │ │ │ └── transformKeys.test.ts.snap │ │ │ ├── transformKeys.test.ts │ │ │ └── typeOf.test.ts │ │ │ ├── transformKeys.ts │ │ │ └── typeOf.ts │ └── tsup.config.ts ├── flatbread │ ├── README.md │ ├── bin │ │ └── flatbread.js │ ├── content │ │ ├── authors │ │ │ ├── me copy.md │ │ │ └── me.md │ │ ├── books │ │ │ ├── edpost_realprogrammersdontusepascal.md │ │ │ ├── gendertrouble_butler.md │ │ │ ├── test_dirty_file.mrakdn │ │ │ ├── theplague_camus.md │ │ │ └── winnerstakeall_anandgiridharas.md │ │ └── posts │ │ │ ├── anotha-one.md │ │ │ └── example-post.md │ ├── package.json │ ├── src │ │ ├── cli │ │ │ ├── index.ts │ │ │ ├── initConfig.ts │ │ │ └── runner.ts │ │ ├── graphql │ │ │ └── server.ts │ │ ├── index.ts │ │ └── utils │ │ │ └── getSchema.ts │ └── tsup.config.ts ├── resolver-svimg │ ├── package.json │ ├── readme.md │ ├── src │ │ └── index.ts │ └── tsup.config.ts ├── source-filesystem │ ├── README.md │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── types.ts │ │ └── utils │ │ │ ├── gatherFileNodes.ts │ │ │ └── tests │ │ │ ├── gatherFileNodes.test.ts │ │ │ ├── mocks.ts │ │ │ └── snapshots │ │ │ ├── gatherFileNodes.test.ts.md │ │ │ └── gatherFileNodes.test.ts.snap │ └── tsup.config.ts ├── transformer-markdown │ ├── README.md │ ├── package.json │ ├── src │ │ ├── graphql │ │ │ └── schema-helpers.ts │ │ ├── index.ts │ │ ├── processors │ │ │ ├── excerpt.ts │ │ │ ├── index.ts │ │ │ ├── markdown.ts │ │ │ ├── timeToRead.ts │ │ │ └── toHTML.ts │ │ └── types.ts │ └── tsup.config.ts └── transformer-yaml │ ├── README.md │ ├── package.json │ ├── src │ ├── index.ts │ └── tests │ │ ├── index.test.ts │ │ ├── index.test.ts.md │ │ ├── index.test.ts.snap │ │ └── snapshots │ │ ├── index.test.ts.md │ │ └── index.test.ts.snap │ └── tsup.config.ts ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── renovate.json ├── scripts ├── bumpVersions.ts ├── publish.ts └── utils │ └── packageManifest.ts └── tsconfig.json /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | browser: true 3 | es2021: true 4 | extends: 5 | - eslint:recommended 6 | - plugin:@typescript-eslint/recommended 7 | - prettier 8 | parser: '@typescript-eslint/parser' 9 | parserOptions: 10 | ecmaVersion: latest 11 | sourceType: module 12 | plugins: 13 | - '@typescript-eslint' 14 | rules: 15 | indent: 16 | - warn 17 | - 2 18 | linebreak-style: 19 | - error 20 | - unix 21 | semi: 22 | - error 23 | - always 24 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [tonyketcham, nbennett320] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: puerhwtf 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: main 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | 12 | strategy: 13 | matrix: 14 | node-version: [16.x] 15 | os: [ubuntu-latest] 16 | fail-fast: false 17 | 18 | steps: 19 | - uses: actions/checkout@v3 20 | 21 | - name: Install pnpm 22 | uses: pnpm/action-setup@v2.2.2 23 | with: 24 | version: ^7.3.0 25 | run_install: false 26 | 27 | - name: Use Node.js ${{ matrix.node-version }} 28 | uses: actions/setup-node@v3 29 | with: 30 | node-version: ${{ matrix.node-version }} 31 | registry-url: https://registry.npmjs.org/ 32 | cache: 'pnpm' 33 | 34 | - name: Set an escape hatch exclusive to this monorepo 35 | id: escape_hatch 36 | run: | 37 | echo "FLATBREAD_CI=true" >> $GITHUB_ENV 38 | 39 | - run: pnpm i 40 | 41 | - name: Build 42 | run: pnpm build 43 | 44 | lint: 45 | runs-on: ${{ matrix.os }} 46 | 47 | strategy: 48 | matrix: 49 | node-version: [16.x] 50 | os: [ubuntu-latest] 51 | fail-fast: false 52 | 53 | steps: 54 | - uses: actions/checkout@v3 55 | 56 | - name: Install pnpm 57 | uses: pnpm/action-setup@v2.2.2 58 | with: 59 | version: ^7.3.0 60 | run_install: false 61 | 62 | - name: Use Node.js ${{ matrix.node-version }} 63 | uses: actions/setup-node@v3 64 | with: 65 | node-version: ${{ matrix.node-version }} 66 | registry-url: https://registry.npmjs.org/ 67 | cache: 'pnpm' 68 | 69 | - name: Set an escape hatch exclusive to this monorepo 70 | id: escape_hatch 71 | run: | 72 | echo "FLATBREAD_CI=true" >> $GITHUB_ENV 73 | 74 | - run: pnpm i 75 | 76 | - name: Lint 77 | run: pnpm lint 78 | 79 | test: 80 | runs-on: ${{ matrix.os }} 81 | 82 | strategy: 83 | matrix: 84 | node-version: [16.x] 85 | os: [ubuntu-latest] 86 | fail-fast: false 87 | 88 | steps: 89 | - uses: actions/checkout@v3 90 | 91 | - name: Install pnpm 92 | uses: pnpm/action-setup@v2.2.2 93 | with: 94 | version: ^7.3.0 95 | run_install: false 96 | 97 | - name: Use Node.js ${{ matrix.node-version }} 98 | uses: actions/setup-node@v3 99 | with: 100 | node-version: ${{ matrix.node-version }} 101 | registry-url: https://registry.npmjs.org/ 102 | cache: 'pnpm' 103 | 104 | - name: Set an escape hatch exclusive to this monorepo 105 | id: escape_hatch 106 | run: | 107 | echo "FLATBREAD_CI=true" >> $GITHUB_ENV 108 | 109 | - run: pnpm i 110 | 111 | - name: Build 112 | run: pnpm build 113 | 114 | - name: Test 115 | run: pnpm test 116 | 117 | integration-sveltekit: 118 | runs-on: ${{ matrix.os }} 119 | 120 | strategy: 121 | matrix: 122 | node-version: [16.x] 123 | os: [ubuntu-latest, windows-latest, macos-latest] 124 | fail-fast: false 125 | 126 | steps: 127 | - uses: actions/checkout@v3 128 | 129 | - name: Install pnpm 130 | uses: pnpm/action-setup@v2.2.2 131 | with: 132 | version: ^7.3.0 133 | run_install: false 134 | 135 | - name: Use Node.js ${{ matrix.node-version }} 136 | uses: actions/setup-node@v3 137 | with: 138 | node-version: ${{ matrix.node-version }} 139 | registry-url: https://registry.npmjs.org/ 140 | cache: 'pnpm' 141 | 142 | - name: Set an escape hatch exclusive to this monorepo 143 | id: escape_hatch 144 | run: | 145 | echo "FLATBREAD_CI=true" >> $GITHUB_ENV 146 | 147 | - run: pnpm i 148 | 149 | - name: Build SvelteKit Integration 150 | run: pnpm play:build 151 | 152 | integration-nextjs: 153 | runs-on: ${{ matrix.os }} 154 | 155 | strategy: 156 | matrix: 157 | node-version: [16.x] 158 | os: [ubuntu-latest, windows-latest, macos-latest] 159 | fail-fast: false 160 | 161 | steps: 162 | - uses: actions/checkout@v3 163 | 164 | - name: Install pnpm 165 | uses: pnpm/action-setup@v2.2.2 166 | with: 167 | version: ^7.3.0 168 | run_install: false 169 | 170 | - name: Use Node.js ${{ matrix.node-version }} 171 | uses: actions/setup-node@v3 172 | with: 173 | node-version: ${{ matrix.node-version }} 174 | registry-url: https://registry.npmjs.org/ 175 | cache: 'pnpm' 176 | 177 | - name: Set an escape hatch exclusive to this monorepo 178 | id: escape_hatch 179 | run: | 180 | echo "FLATBREAD_CI=true" >> $GITHUB_ENV 181 | 182 | - run: pnpm i 183 | 184 | - name: Build Next.js integration 185 | # TODO: extract the Flatbread build to a separate job that this job can depend on 186 | run: pnpm build && cd examples/nextjs && pnpm build 187 | -------------------------------------------------------------------------------- /.github/workflows/pr-auto-update.yml: -------------------------------------------------------------------------------- 1 | name: Auto-update 2 | # Auto-update only listens to `push` events. 3 | # If a pull request is already outdated when enabling auto-merge, manually click on the "Update branch" button a first time to avoid having to wait for another commit to land on the base branch for the pull request to be updated. 4 | on: push 5 | # If pull requests are always based on the same branches, only triggering the workflow when these branches receive new commits will minimize the workflow execution. 6 | # on: 7 | # push: 8 | # branches: 9 | # - main 10 | 11 | jobs: 12 | Auto: 13 | name: Auto-update 14 | runs-on: ubuntu-18.04 15 | steps: 16 | - uses: tibdex/auto-update@v2 17 | with: 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # packages & build files 2 | **/node_modules 3 | **/dist/** 4 | 5 | # temp files 6 | **.swp 7 | .vscode 8 | .DS_Store 9 | 10 | # logs 11 | yarn-error.log 12 | .pnpm-debug.log -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | save-exact=true -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/.svelte-kit/** 2 | **/static/** 3 | **/build/** 4 | **/dist/** 5 | **/node_modules/** 6 | **/pnpm-lock.yaml 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "pwa-node", 9 | "request": "launch", 10 | "name": "Launch Program", 11 | "skipFiles": ["/**"], 12 | "program": "${workspaceFolder}/packages/transformer-markdown/src/index.ts", 13 | "outFiles": ["${workspaceFolder}/**/*.js"] 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/flatbread/README.md -------------------------------------------------------------------------------- /assets/Flatbread V 3 Export@1-1080x1080.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/Flatbread V 3 Export@1-1080x1080.jpg -------------------------------------------------------------------------------- /assets/Flatbread V 3 Export@1-1080x1080.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/Flatbread V 3 Export@1-1080x1080.png -------------------------------------------------------------------------------- /assets/flatbread logo v2 x4@1-1728x1080 centered header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/flatbread logo v2 x4@1-1728x1080 centered header.png -------------------------------------------------------------------------------- /assets/flatbread logo v2 x4@1-1728x1080 centered.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/flatbread logo v2 x4@1-1728x1080 centered.png -------------------------------------------------------------------------------- /assets/flatbread logo v2 x4@1-1728x1080.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/flatbread logo v2 x4@1-1728x1080.png -------------------------------------------------------------------------------- /assets/flatbread v1@1-1080x1080.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/flatbread v1@1-1080x1080.png -------------------------------------------------------------------------------- /assets/flatbread v1@1-772x634.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/assets/flatbread v1@1-772x634.png -------------------------------------------------------------------------------- /ava.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | files: ['packages/**/*.test.(j|t)s'], 3 | extensions: { 4 | js: true, 5 | ts: 'module', 6 | }, 7 | nodeArguments: [ 8 | '--loader=ts-node/esm', 9 | '--experimental-specifier-resolution=node', 10 | ], 11 | }; 12 | -------------------------------------------------------------------------------- /examples/content/README.md: -------------------------------------------------------------------------------- 1 | # Example content 2 | 3 | This is a central content store including various directories of content used for example integrations with Flatbread. This content is also used in our internal testing. 4 | 5 | To keep things DRY and easier to maintain, we've pointed or symlinked all content uses to this set. 6 | -------------------------------------------------------------------------------- /examples/content/markdown/authors/daes.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: ab2c 3 | name: Daes 4 | entity: Human 5 | enjoys: 6 | - cats 7 | - coffee 8 | - design 9 | friend: 40s3 10 | date_joined: 2021-04-22T16:41:59.558Z 11 | skills: 12 | sitting: 304 13 | breathing: 1.034234 14 | liquid_consumption: -100 15 | existence: etheral 16 | sports: 3 17 | --- 18 | -------------------------------------------------------------------------------- /examples/content/markdown/authors/eva.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 40s3 3 | name: Eva 4 | entity: Cat 5 | enjoys: 6 | - sitting 7 | - standing 8 | - mow mow 9 | - sleepy time 10 | - attention 11 | friend: 2a3e 12 | image: eva.svg 13 | date_joined: 2002-02-25T16:41:59.558Z 14 | skills: 15 | sitting: 100000 16 | breathing: 4.7 17 | liquid_consumption: 10 18 | existence: funky 19 | sports: -200 20 | --- 21 | -------------------------------------------------------------------------------- /examples/content/markdown/authors/tony.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 2a3e 3 | name: Tony 4 | entity: Human 5 | enjoys: 6 | - cats 7 | - tea 8 | - making this 9 | friend: 40s3 10 | image: tony.svg 11 | date_joined: 2021-02-25T16:41:59.558Z 12 | skills: 13 | sitting: 204 14 | breathing: 7.07 15 | liquid_consumption: 100 16 | existence: simulation 17 | sports: -2 18 | --- 19 | -------------------------------------------------------------------------------- /examples/content/markdown/authors/ushi.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 1111 3 | name: Ushi 4 | entity: Cat 5 | enjoys: 6 | - peeing in the cat tower 7 | - being shaped like an egg 8 | - violence 9 | date_joined: 2021-10-25T16:41:59.558Z 10 | skills: 11 | sitting: 100 12 | breathing: 7 13 | liquid_consumption: 6 14 | existence: bittersweet 15 | sports: 400 16 | --- 17 | -------------------------------------------------------------------------------- /examples/content/markdown/authors/yoshi.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: r3c6 3 | name: Yoshi 4 | entity: Cat 5 | enjoys: 6 | - talking 7 | - encroaching upon personal space 8 | - being concerned 9 | - smooth jazz 10 | friend: ab2c 11 | date_joined: 2018-10-25T16:23:59.558Z 12 | skills: 13 | sitting: 10 14 | breathing: 70 15 | liquid_consumption: 8 16 | existence: being nice 17 | sports: 400 18 | --- 19 | -------------------------------------------------------------------------------- /examples/content/markdown/deeply-nested/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 92348fds-453fdh-59ddsd-3332-65586 3 | title: 'Test post A' 4 | deeply: 5 | nested: item 6 | array: 7 | - array item 8 | array2: 9 | - obj: object item 10 | array3: 11 | - obj: 12 | test: array item obj 13 | --- 14 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/anotha-one.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 92348fds-453fdh-59ddsd-3332-09876 3 | title: 'Test post A' 4 | authors: 5 | - 40s3 6 | - 2a3e 7 | rating: 84.3 8 | --- 9 | 10 | # Here are some great words I hope you query 11 | 12 | ```js 13 | const hello = 'plus a code block'; 14 | ``` 15 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/art/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 92348fds-453fdh-59ddsd-3654-3524 3 | title: 'Test post in category art' 4 | authors: 5 | - 40s3 6 | rating: 84.3 7 | --- 8 | 9 | # Here are some great words I hope you query 10 | 11 | ```js 12 | const hello = 'plus a code block'; 13 | ``` 14 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/b.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 2348fds-563fdh-59ddsd-3332-09876 3 | title: 'Test post B' 4 | authors: 5 | - 1111 6 | - ab2c 7 | rating: 44 8 | --- 9 | 10 | Lorem ipsum 11 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/category with space/test.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 92348fds-453adh-59ddsd-3332-09876 3 | title: 'Test post category with space' 4 | authors: 5 | - 40s3 6 | - 2a3e 7 | rating: 84.3 8 | --- 9 | 10 | # Here are some great words I hope you query 11 | 12 | ```js 13 | const hello = 'plus a code block'; 14 | ``` 15 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/code/another-category-test.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 92348fds-453fdh-59ddsd-3332-3524 3 | title: 'Test post in category code' 4 | authors: 5 | - 40s3 6 | - 2a3e 7 | rating: 84.3 8 | --- 9 | 10 | # Here are some great words I hope you query 11 | 12 | ```js 13 | const hello = 'plus a code block'; 14 | ``` 15 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/example-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: sdfsdf-23423-sdfsd-23444-dfghf 3 | title: 'Example post of things' 4 | authors: 5 | - 2a3e 6 | - 40s3 7 | rating: 74 8 | --- 9 | 10 | # My cat is so little 11 | 12 | ## she is so small 13 | 14 | ### she is in fact 15 | 16 | **2 apples tall** 17 | -------------------------------------------------------------------------------- /examples/content/markdown/posts/soup.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: jksfd4-234fdh-5345fj-3455-09836 3 | title: 'Soup Reflection' 4 | authors: 5 | - r3c6 6 | - ab2c 7 | rating: 96 8 | --- 9 | 10 | Mushroom soup would hit the spot right now. Or Tom Kha. 11 | 12 | _random non-soup content but in italics_ 13 | -------------------------------------------------------------------------------- /examples/content/yaml/authors/eva.yml: -------------------------------------------------------------------------------- 1 | id: 40s3 2 | name: Eva 3 | enjoys: 4 | - sitting 5 | - standing 6 | - mow mow 7 | - sleepy time 8 | - attention 9 | friend: 2a3e 10 | date_joined: 2002-02-25T16:41:59.558Z 11 | skills: 12 | sitting: 100000 13 | breathing: 4.7 14 | liquid_consumption: 10 15 | existence: funky 16 | sports: -200 17 | cat_pat: 154000 18 | -------------------------------------------------------------------------------- /examples/content/yaml/authors/me.yml: -------------------------------------------------------------------------------- 1 | id: 2a3e 2 | name: Tony 3 | enjoys: 4 | - cats 5 | - tea 6 | - making this 7 | friend: 40s3 8 | date_joined: 2021-02-25T16:41:59.558Z 9 | skills: 10 | sitting: 204 11 | breathing: 7.07 12 | liquid_consumption: 100 13 | existence: simulation 14 | sports: -2 15 | cat_pat: 1500 16 | -------------------------------------------------------------------------------- /examples/content/yaml/posts/anotha-one.yml: -------------------------------------------------------------------------------- 1 | id: 92348fds-453fdh-59ddsd-3332-09876 2 | title: 'Senior cat list' 3 | authors: 4 | - 40s3 5 | - 2a3e 6 | rating: 100 7 | -------------------------------------------------------------------------------- /examples/content/yaml/posts/example-post.yml: -------------------------------------------------------------------------------- 1 | id: sdfsdf-23423-sdfsd-23444-dfghf 2 | title: 'Example post of things' 3 | authors: 4 | - 2a3e 5 | - 40s3 6 | rating: 74 7 | -------------------------------------------------------------------------------- /examples/nextjs/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /examples/nextjs/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | -------------------------------------------------------------------------------- /examples/nextjs/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /examples/nextjs/content: -------------------------------------------------------------------------------- 1 | ../content -------------------------------------------------------------------------------- /examples/nextjs/flatbread.config.ts: -------------------------------------------------------------------------------- 1 | import { createSvImgField } from '@flatbread/resolver-svimg'; 2 | import { 3 | defineConfig, 4 | transformerMarkdown, 5 | transformerYaml, 6 | sourceFilesystem, 7 | } from 'flatbread'; 8 | 9 | const transformerConfig = { 10 | markdown: { 11 | gfm: true, 12 | externalLinks: true, 13 | }, 14 | }; 15 | 16 | export default defineConfig({ 17 | source: sourceFilesystem(), 18 | transformer: [transformerMarkdown(transformerConfig), transformerYaml()], 19 | content: [ 20 | { 21 | path: 'content/markdown/posts', 22 | collection: 'Post', 23 | refs: { 24 | authors: 'Author', 25 | }, 26 | }, 27 | { 28 | path: 'content/markdown/posts/[category]/[slug].md', 29 | collection: 'PostCategory', 30 | refs: { 31 | authors: 'Author', 32 | }, 33 | }, 34 | { 35 | path: 'content/markdown/posts/**/*.md', 36 | collection: 'PostCategoryBlob', 37 | refs: { 38 | authors: 'Author', 39 | }, 40 | }, 41 | { 42 | path: 'content/markdown/authors', 43 | collection: 'Author', 44 | refs: { 45 | friend: 'Author', 46 | }, 47 | overrides: [ 48 | createSvImgField('image', { 49 | inputDir: 'static/authorImages', 50 | outputDir: 'static/g', 51 | srcGenerator: (path) => '/g/' + path, 52 | }), 53 | ], 54 | }, 55 | { 56 | path: 'content/yaml/authors', 57 | collection: 'YamlAuthor', 58 | refs: { 59 | friend: 'YamlAuthor', 60 | }, 61 | }, 62 | { 63 | path: 'content/markdown/deeply-nested', 64 | collection: 'OverrideTest', 65 | overrides: [ 66 | { 67 | field: 'deeply.nested', 68 | type: 'String', 69 | resolve: (source) => String(source).toUpperCase(), 70 | }, 71 | { 72 | field: 'array[]', 73 | type: 'String', 74 | resolve: (source) => source.map((s: any) => s.toUpperCase()), 75 | }, 76 | { 77 | field: 'array2[]obj', 78 | type: 'String', 79 | resolve: (source) => source.toUpperCase(), 80 | }, 81 | { 82 | field: 'array3[]obj.test', 83 | type: 'String', 84 | resolve: (source) => source.toUpperCase(), 85 | }, 86 | ], 87 | }, 88 | ], 89 | }); 90 | -------------------------------------------------------------------------------- /examples/nextjs/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /examples/nextjs/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | }; 6 | 7 | module.exports = nextConfig; 8 | -------------------------------------------------------------------------------- /examples/nextjs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "flatbread start -- next dev", 7 | "build": "flatbread start -- next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "flatbread": "workspace:*", 13 | "next": "12.2.3", 14 | "react": "18.2.0", 15 | "react-dom": "18.2.0" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "16.11.45", 19 | "@types/react": "18.0.15", 20 | "@types/react-dom": "18.0.6", 21 | "eslint": "7.32.0", 22 | "eslint-config-next": "12.2.3", 23 | "typescript": "4.7.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /examples/nextjs/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.css'; 2 | import type { AppProps } from 'next/app'; 3 | 4 | function MyApp({ Component, pageProps }: AppProps) { 5 | return ; 6 | } 7 | 8 | export default MyApp; 9 | -------------------------------------------------------------------------------- /examples/nextjs/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next'; 3 | 4 | type Data = { 5 | name: string; 6 | }; 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }); 13 | } 14 | -------------------------------------------------------------------------------- /examples/nextjs/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import Head from 'next/head'; 3 | import Image from 'next/image'; 4 | import styles from '../styles/Home.module.css'; 5 | 6 | const Home: NextPage = () => { 7 | return ( 8 | 69 | ); 70 | }; 71 | 72 | export default Home; 73 | -------------------------------------------------------------------------------- /examples/nextjs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/nextjs/public/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /examples/nextjs/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 0 2rem; 3 | } 4 | 5 | .main { 6 | min-height: 100vh; 7 | padding: 4rem 0; 8 | flex: 1; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: center; 12 | align-items: center; 13 | } 14 | 15 | .footer { 16 | display: flex; 17 | flex: 1; 18 | padding: 2rem 0; 19 | border-top: 1px solid #eaeaea; 20 | justify-content: center; 21 | align-items: center; 22 | } 23 | 24 | .footer a { 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | flex-grow: 1; 29 | } 30 | 31 | .title a { 32 | color: #0070f3; 33 | text-decoration: none; 34 | } 35 | 36 | .title a:hover, 37 | .title a:focus, 38 | .title a:active { 39 | text-decoration: underline; 40 | } 41 | 42 | .title { 43 | margin: 0; 44 | line-height: 1.15; 45 | font-size: 4rem; 46 | } 47 | 48 | .title, 49 | .description { 50 | text-align: center; 51 | } 52 | 53 | .description { 54 | margin: 4rem 0; 55 | line-height: 1.5; 56 | font-size: 1.5rem; 57 | } 58 | 59 | .code { 60 | background: #fafafa; 61 | border-radius: 5px; 62 | padding: 0.75rem; 63 | font-size: 1.1rem; 64 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 65 | Bitstream Vera Sans Mono, Courier New, monospace; 66 | } 67 | 68 | .grid { 69 | display: flex; 70 | align-items: center; 71 | justify-content: center; 72 | flex-wrap: wrap; 73 | max-width: 800px; 74 | } 75 | 76 | .card { 77 | margin: 1rem; 78 | padding: 1.5rem; 79 | text-align: left; 80 | color: inherit; 81 | text-decoration: none; 82 | border: 1px solid #eaeaea; 83 | border-radius: 10px; 84 | transition: color 0.15s ease, border-color 0.15s ease; 85 | max-width: 300px; 86 | } 87 | 88 | .card:hover, 89 | .card:focus, 90 | .card:active { 91 | color: #0070f3; 92 | border-color: #0070f3; 93 | } 94 | 95 | .card h2 { 96 | margin: 0 0 1rem 0; 97 | font-size: 1.5rem; 98 | } 99 | 100 | .card p { 101 | margin: 0; 102 | font-size: 1.25rem; 103 | line-height: 1.5; 104 | } 105 | 106 | .logo { 107 | height: 1em; 108 | margin-left: 0.5rem; 109 | } 110 | 111 | @media (max-width: 600px) { 112 | .grid { 113 | width: 100%; 114 | flex-direction: column; 115 | } 116 | } 117 | 118 | @media (prefers-color-scheme: dark) { 119 | .card, 120 | .footer { 121 | border-color: #222; 122 | } 123 | .code { 124 | background: #111; 125 | } 126 | .logo img { 127 | filter: invert(1); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /examples/nextjs/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | a { 10 | color: inherit; 11 | text-decoration: none; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | @media (prefers-color-scheme: dark) { 19 | html { 20 | color-scheme: dark; 21 | } 22 | body { 23 | color: white; 24 | background: black; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /examples/nextjs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": [ 19 | "next-env.d.ts", 20 | "**/*.ts", 21 | "**/*.tsx", 22 | "test.js", 23 | "flatbread.config.mts", 24 | "flatbread.config.mts" 25 | ], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /examples/sveltekit/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier', 8 | ], 9 | plugins: ['svelte3', '@typescript-eslint'], 10 | ignorePatterns: ['*.cjs'], 11 | overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], 12 | settings: { 13 | 'svelte3/typescript': () => require('typescript'), 14 | }, 15 | parserOptions: { 16 | sourceType: 'module', 17 | ecmaVersion: 2019, 18 | }, 19 | env: { 20 | browser: true, 21 | es2017: true, 22 | node: true, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /examples/sveltekit/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env -------------------------------------------------------------------------------- /examples/sveltekit/content: -------------------------------------------------------------------------------- 1 | ../content -------------------------------------------------------------------------------- /examples/sveltekit/flatbread.config.js: -------------------------------------------------------------------------------- 1 | import { createSvImgField } from '@flatbread/resolver-svimg'; 2 | import { 3 | defineConfig, 4 | transformerMarkdown, 5 | transformerYaml, 6 | sourceFilesystem, 7 | } from 'flatbread'; 8 | 9 | const transformerConfig = { 10 | markdown: { 11 | gfm: true, 12 | externalLinks: true, 13 | }, 14 | }; 15 | 16 | export default defineConfig({ 17 | source: sourceFilesystem(), 18 | transformer: [transformerMarkdown(transformerConfig), transformerYaml()], 19 | content: [ 20 | { 21 | path: 'content/markdown/posts', 22 | collection: 'Post', 23 | refs: { 24 | authors: 'Author', 25 | }, 26 | }, 27 | { 28 | path: 'content/markdown/posts/[category]/[slug].md', 29 | collection: 'PostCategory', 30 | refs: { 31 | authors: 'Author', 32 | }, 33 | }, 34 | { 35 | path: 'content/markdown/posts/**/*.md', 36 | collection: 'PostCategoryBlob', 37 | refs: { 38 | authors: 'Author', 39 | }, 40 | }, 41 | { 42 | path: 'content/markdown/authors', 43 | collection: 'Author', 44 | refs: { 45 | friend: 'Author', 46 | }, 47 | overrides: [ 48 | createSvImgField('image', { 49 | inputDir: 'static/authorImages', 50 | outputDir: 'static/g', 51 | srcGenerator: (path) => '/g/' + path, 52 | }), 53 | ], 54 | }, 55 | { 56 | path: 'content/yaml/authors', 57 | collection: 'YamlAuthor', 58 | refs: { 59 | friend: 'YamlAuthor', 60 | }, 61 | }, 62 | { 63 | path: 'content/markdown/deeply-nested', 64 | collection: 'OverrideTest', 65 | overrides: [ 66 | { 67 | field: 'deeply.nested', 68 | type: 'String', 69 | test: undefined, 70 | test2: null, 71 | resolve: (source) => String(source).toUpperCase(), 72 | }, 73 | { 74 | field: 'array[]', 75 | type: 'String', 76 | resolve: (source) => source.map((s) => s.toUpperCase()), 77 | }, 78 | { 79 | field: 'array2[]obj', 80 | type: 'String', 81 | resolve: (source) => source.toUpperCase(), 82 | }, 83 | { 84 | field: 'array3[]obj.test', 85 | type: 'String', 86 | resolve: (source) => source.toUpperCase(), 87 | }, 88 | ], 89 | }, 90 | ], 91 | }); 92 | -------------------------------------------------------------------------------- /examples/sveltekit/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/playground", 3 | "private": true, 4 | "version": "1.0.0", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 8 | "directory": "playground" 9 | }, 10 | "author": "Tony Ketcham ", 11 | "license": "MIT", 12 | "bugs": { 13 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 14 | }, 15 | "scripts": { 16 | "flat": "flatbread start", 17 | "dev": "flatbread start -- vite dev", 18 | "flatbread:help": "flatbread start -h", 19 | "build": "flatbread start -- vite build", 20 | "check-env": "node -e 'console.log(process.env)' | grep npm", 21 | "preview": "vite preview", 22 | "check": "svelte-check --tsconfig ./tsconfig.json", 23 | "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", 24 | "lint": "prettier --ignore-path .gitignore --check --plugin-search-dir=. . && eslint --ignore-path .gitignore .", 25 | "format": "prettier --ignore-path .gitignore --write --plugin-search-dir=. .", 26 | "transpile": "tsc --watch", 27 | "flatbread:withnpm": "flatbread start -X npm -- -h" 28 | }, 29 | "devDependencies": { 30 | "@flatbread/transformer-yaml": "workspace:*", 31 | "@sveltejs/adapter-static": "next", 32 | "@sveltejs/kit": "next", 33 | "@typescript-eslint/eslint-plugin": "4.33.0", 34 | "@typescript-eslint/parser": "4.33.0", 35 | "autoprefixer": "10.4.7", 36 | "eslint": "7.32.0", 37 | "eslint-config-prettier": "8.5.0", 38 | "eslint-plugin-svelte3": "3.4.1", 39 | "flatbread": "workspace:*", 40 | "graphql": "16.5.0", 41 | "prettier": "2.7.1", 42 | "prettier-plugin-svelte": "2.4.0", 43 | "svelte": "3.49.0", 44 | "svelte-check": "2.8.0", 45 | "svelte-preprocess": "4.10.7", 46 | "tailwindcss": "3.1.6", 47 | "tslib": "2.4.0", 48 | "typescript": "4.7.4", 49 | "vite": "3.0.4", 50 | "vite-plugin-transform": "1.1.3" 51 | }, 52 | "type": "module", 53 | "dependencies": { 54 | "svimg": "3.0.0" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /examples/sveltekit/postcss.config.cjs: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss'); 2 | const autoprefixer = require('autoprefixer'); 3 | 4 | const config = { 5 | plugins: [ 6 | //Some plugins, like tailwindcss/nesting, need to run before Tailwind, 7 | tailwindcss(), 8 | //But others, like autoprefixer, need to run after, 9 | autoprefixer, 10 | ], 11 | }; 12 | 13 | module.exports = config; 14 | -------------------------------------------------------------------------------- /examples/sveltekit/src/app.css: -------------------------------------------------------------------------------- 1 | /* Write your global styles here, in PostCSS syntax */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; 5 | -------------------------------------------------------------------------------- /examples/sveltekit/src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Flatbread | Playground 8 | %sveltekit.head% 9 | 10 | 11 |
%sveltekit.body%
12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/sveltekit/src/global.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /examples/sveltekit/src/lib/components/Label.svelte: -------------------------------------------------------------------------------- 1 |

4 | 5 |

6 | -------------------------------------------------------------------------------- /examples/sveltekit/src/lib/components/Pane.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
8 | {#if label} 9 | 10 | {/if} 11 | 12 |
13 | -------------------------------------------------------------------------------- /examples/sveltekit/src/routes/__layout.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
9 |

Flatbread Playground

10 | GraphQL Explorer 33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /examples/sveltekit/src/routes/index.svelte: -------------------------------------------------------------------------------- 1 | 82 | 83 | 99 | 100 |
101 | 102 |
105 |       
106 |         {JSON.stringify(data, null, 2)}
107 |       
108 |     
109 |
110 | 111 | {#each data.allPostCategories as post, _ (post.id)} 112 |
113 |

{post.title}

114 |
    115 |
  • 116 |
    117 | {#each post.authors as author} 118 |
    119 |
    120 | 121 |
    122 | {author.name} 123 |
    124 | {/each} 125 |
    126 |
  • 127 |
  • 128 | Rating: {post.rating} 129 |
  • 130 |
131 |
{@html post._content.html}
132 |
133 | {/each} 134 |
135 |
136 | -------------------------------------------------------------------------------- /examples/sveltekit/static/authorImages/eva.svg: -------------------------------------------------------------------------------- 1 | image/svg+xmlBotttsPablo Stanleyhttps://bottts.com/Florian Körner undefined -------------------------------------------------------------------------------- /examples/sveltekit/static/authorImages/tony.svg: -------------------------------------------------------------------------------- 1 | image/svg+xmlBotttsPablo Stanleyhttps://bottts.com/Florian Körner undefined -------------------------------------------------------------------------------- /examples/sveltekit/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/favicon.png -------------------------------------------------------------------------------- /examples/sveltekit/static/g/eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/g/eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.avif -------------------------------------------------------------------------------- /examples/sveltekit/static/g/eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/g/eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.svg -------------------------------------------------------------------------------- /examples/sveltekit/static/g/eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/g/eva.1255970.f1373bc79c9f4170bc3f5a14e84ae78f.webp -------------------------------------------------------------------------------- /examples/sveltekit/static/g/tony.1255970.4b9b7277a1ba63096536c038ed848dc9.avif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/g/tony.1255970.4b9b7277a1ba63096536c038ed848dc9.avif -------------------------------------------------------------------------------- /examples/sveltekit/static/g/tony.1255970.4b9b7277a1ba63096536c038ed848dc9.svg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/g/tony.1255970.4b9b7277a1ba63096536c038ed848dc9.svg -------------------------------------------------------------------------------- /examples/sveltekit/static/g/tony.1255970.4b9b7277a1ba63096536c038ed848dc9.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/examples/sveltekit/static/g/tony.1255970.4b9b7277a1ba63096536c038ed848dc9.webp -------------------------------------------------------------------------------- /examples/sveltekit/svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from 'svelte-preprocess'; 2 | import adapter from '@sveltejs/adapter-static'; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | extensions: ['.svelte'], 7 | preprocess: [preprocess({})], 8 | 9 | kit: { 10 | adapter: adapter(), 11 | prerender: { 12 | default: true, 13 | }, 14 | }, 15 | }; 16 | 17 | export default config; 18 | -------------------------------------------------------------------------------- /examples/sveltekit/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | content: ['./src/**/*.{html,js,svelte,ts}'], 3 | 4 | theme: { 5 | extend: {}, 6 | }, 7 | 8 | plugins: [], 9 | }; 10 | 11 | module.exports = config; 12 | -------------------------------------------------------------------------------- /examples/sveltekit/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "moduleResolution": "node", 5 | "module": "esnext", 6 | "lib": ["es2020", "DOM"], 7 | "target": "es2020", 8 | /** 9 | svelte-preprocess cannot figure out whether you have a value or a type, so tell TypeScript 10 | to enforce using \`import type\` instead of \`import\` for Types. 11 | */ 12 | "importsNotUsedAsValues": "error", 13 | "isolatedModules": true, 14 | "resolveJsonModule": true, 15 | /** 16 | To have warnings/errors of the Svelte compiler at the correct position, 17 | enable source maps by default. 18 | */ 19 | "sourceMap": true, 20 | "esModuleInterop": true, 21 | "skipLibCheck": true, 22 | "forceConsistentCasingInFileNames": true, 23 | "baseUrl": ".", 24 | "allowJs": true, 25 | "checkJs": true, 26 | "paths": { 27 | "$lib": ["src/lib"], 28 | "$lib/*": ["src/lib/*"] 29 | } 30 | }, 31 | "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.ts", "src/**/*.svelte"] 32 | } 33 | -------------------------------------------------------------------------------- /examples/sveltekit/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()], 6 | }); 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/monorepo", 3 | "private": true, 4 | "description": "Eat your relational markdown data and query it, too, with GraphQL inside damn near any framework (statement awaiting peer-review).", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "preinstall": "npx only-allow pnpm", 9 | "wipe": "npkill", 10 | "wipe:dist": "pnpm -r exec rm -rf ./dist", 11 | "build": "pnpm -r --filter !{examples/*} build", 12 | "build:examples": "pnpm -r --filter {examples/*} build", 13 | "build:types": "pnpm -r --filter !{examples/*} exec -- tsup --dts-only", 14 | "dev": "pnpm -r --parallel --filter !{examples/*} dev", 15 | "lint:eslint": "eslint packages/**/src", 16 | "lint:prettier": "prettier --check --plugin-search-dir=. .", 17 | "lint": "pnpm lint:prettier", 18 | "lint:fix": "pnpm lint:fix:prettier", 19 | "lint:fix:prettier": "pretty-quick --staged", 20 | "play": "cd examples/sveltekit && pnpm dev", 21 | "play:build": "pnpm build && cd examples/sveltekit && pnpm build", 22 | "prepublish:ci": "pnpm -r update", 23 | "publish:ci": "esno scripts/publish.ts", 24 | "bump": "esno scripts/bumpVersions.ts", 25 | "test": "ava", 26 | "dev:test": "ava --watch --verbose", 27 | "prepare": "husky install" 28 | }, 29 | "repository": { 30 | "type": "git", 31 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git" 32 | }, 33 | "author": "Tony Ketcham ", 34 | "license": "MIT", 35 | "bugs": { 36 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 37 | }, 38 | "homepage": "https://github.com/FlatbreadLabs/flatbread#readme", 39 | "dependencies": { 40 | "@flatbread/config": "workspace:*", 41 | "@flatbread/core": "workspace:*", 42 | "@flatbread/resolver-svimg": "workspace:*", 43 | "@flatbread/source-filesystem": "workspace:*", 44 | "@flatbread/transformer-markdown": "workspace:*", 45 | "flatbread": "workspace:*" 46 | }, 47 | "devDependencies": { 48 | "@ava/typescript": "3.0.1", 49 | "@nrwl/workspace": "14.4.3", 50 | "@types/inquirer": "8.2.1", 51 | "@types/node": "16.11.47", 52 | "ava": "4.3.1", 53 | "bumpp": "8.2.1", 54 | "eslint": "7", 55 | "esno": "0.16.3", 56 | "export-size": "0.5.2", 57 | "husky": "8.0.1", 58 | "inquirer": "9.1.0", 59 | "kleur": "4.1.5", 60 | "npkill": "0.8.3", 61 | "prettier": "2.7.1", 62 | "pretty-quick": "3.1.3", 63 | "ts-node": "10.9.1", 64 | "tsconfig-paths": "4.0.0", 65 | "tsup": "6.2.1", 66 | "typescript": "4.7.4" 67 | }, 68 | "pnpm": { 69 | "peerDependencyRules": { 70 | "allowedVersions": { 71 | "graphql": "^16.0.1" 72 | } 73 | } 74 | }, 75 | "husky": { 76 | "hooks": { 77 | "pre-commit": "pretty-quick --staged" 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /packages/config/README.md: -------------------------------------------------------------------------------- 1 | # @flatbread/config 📐 2 | 3 | > Provides a typed config helper function, config validation, and auto-config retrieval. 4 | 5 | ## 💾 Install 6 | 7 | Use `pnpm`, `npm`, or `yarn`: 8 | 9 | ```bash 10 | pnpm i @flatbread/config 11 | ``` 12 | 13 | Valid config filenames: 14 | 15 | - `flatbread.config.js` 16 | - `flatbread.config.mjs` 17 | - `flatbread.config.cjs` 18 | - `flatbread.config.ts` 19 | - `flatbread.config.mts` 20 | - `flatbread.config.cts` 21 | 22 | ## 👩‍🍳 Typical Usage 23 | 24 | ### defineConfig(config) 25 | 26 | Provides assistance to your IDE for building your config 27 | 28 | ```js 29 | // flatbread.config.js 30 | import defineConfig from '@flatbread/config'; 31 | 32 | export default defineConfig({ 33 | ... 34 | }); 35 | ``` 36 | 37 | ## 😳 Advanced Usage 38 | 39 | If you're building something custom, piecemealed from these modules, you can make use of schema validation & config auto-loading. 40 | 41 | ### `async loadConfig(...)` 42 | 43 | Pulls the user config from an optionally specified filepath. By default, this will search the current working directory. 44 | 45 | #### options 46 | 47 | - Type: `{cwd?: string | undefined;}` 48 | - Default: `{}` 49 | 50 | Options for loading the config file, defaults to `{}`. Can pass in `cwd` as a path `string` to override the current working directory. 51 | 52 | ### `validateConfigHasExports(config)` 53 | 54 | Validate that the user config has a default export that is an object. 55 | 56 | ### `validateConfigStructure(config)` 57 | 58 | Validate that the user config has `source` and `content` properties. 59 | -------------------------------------------------------------------------------- /packages/config/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/config", 3 | "version": "1.0.0-alpha.8", 4 | "description": "Flatbread's user config processing", 5 | "type": "module", 6 | "publishConfig": { 7 | "main": "dist/index.js", 8 | "typings": "dist/index.d.ts" 9 | }, 10 | "scripts": { 11 | "build": "tsup", 12 | "dev": "tsup --watch src" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 17 | "directory": "packages/config" 18 | }, 19 | "homepage": "https://github.com/FlatbreadLabs/flatbread/tree/main/packages/config#readme", 20 | "author": "Tony Ketcham ", 21 | "license": "MIT", 22 | "bugs": { 23 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 24 | }, 25 | "exports": { 26 | ".": { 27 | "import": "./dist/index.js", 28 | "require": "./dist/index.cjs" 29 | } 30 | }, 31 | "main": "dist/index.js", 32 | "module": "dist/index.js", 33 | "types": "dist/index.d.ts", 34 | "files": [ 35 | "dist", 36 | "*.d.ts" 37 | ], 38 | "dependencies": { 39 | "esbuild": "0.15.1" 40 | }, 41 | "devDependencies": { 42 | "@flatbread/core": "workspace:*", 43 | "@types/node": "16.11.47", 44 | "tsup": "6.2.1", 45 | "typescript": "4.7.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/config/src/errors.ts: -------------------------------------------------------------------------------- 1 | import colors from 'kleur'; 2 | import { FLATBREAD_CONFIG_FILE_NAMES } from './filenames'; 3 | 4 | const VALID_CONFIG_NAMES_MESSAGE = `Valid config filenames are:\n\t${colors.green( 5 | FLATBREAD_CONFIG_FILE_NAMES.join('\n\t') 6 | )} 7 | `; 8 | 9 | /** 10 | * If no config file is found, throw an error. 11 | */ 12 | export class NoConfigFoundError extends Error { 13 | constructor() { 14 | super(); 15 | this.message = 16 | colors.red( 17 | `No config file found. Please declare one! 😅\n 18 | ` 19 | ) + VALID_CONFIG_NAMES_MESSAGE; 20 | } 21 | } 22 | 23 | /** 24 | * If multiple config files are found, throw an error. 25 | */ 26 | export class TooManyConfigsFoundError extends Error { 27 | constructor(matchingFiles: string[]) { 28 | super(); 29 | this.message = 30 | colors.red(`Multiple config files found (${colors.gray( 31 | matchingFiles.join(', ') 32 | )}). 33 | 34 | Please declare ${colors.bold('only')} one! 😅\n 35 | `) + VALID_CONFIG_NAMES_MESSAGE; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/config/src/filenames.ts: -------------------------------------------------------------------------------- 1 | export type ConfigFileExtension = 'js' | 'mjs' | 'cjs' | 'ts' | 'mts' | 'cts'; 2 | export type ConfigFileName = `flatbread.config.${ConfigFileExtension}`; 3 | 4 | export const FLATBREAD_CONFIG_FILE_NAMES: ConfigFileName[] = [ 5 | 'flatbread.config.js', 6 | 'flatbread.config.mjs', 7 | 'flatbread.config.cjs', 8 | 9 | 'flatbread.config.ts', 10 | 'flatbread.config.mts', 11 | 'flatbread.config.cts', 12 | ]; 13 | 14 | export const FLATBREAD_CONFIG_FILE_REGEX = /^flatbread\.config\.[mc]?[jt]s$/; 15 | -------------------------------------------------------------------------------- /packages/config/src/index.ts: -------------------------------------------------------------------------------- 1 | import { FlatbreadConfig } from '@flatbread/core'; 2 | 3 | export { loadConfig } from './load'; 4 | 5 | /** 6 | * Type-assisted config builder 7 | * 8 | * @param config flatbread instance options 9 | * @returns flatbread config 10 | */ 11 | export function defineConfig(config: FlatbreadConfig): FlatbreadConfig { 12 | return config; 13 | } 14 | 15 | export default defineConfig; 16 | -------------------------------------------------------------------------------- /packages/config/src/load.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ConfigResult, 3 | FlatbreadConfig, 4 | initializeConfig, 5 | } from '@flatbread/core'; 6 | import { build } from 'esbuild'; 7 | import fs from 'node:fs/promises'; 8 | import path from 'node:path'; 9 | import { pathToFileURL } from 'node:url'; 10 | import { NoConfigFoundError, TooManyConfigsFoundError } from './errors'; 11 | import { 12 | validateConfigHasDefaultExport, 13 | validateConfigStructure, 14 | } from './validate'; 15 | import { ConfigFileName, FLATBREAD_CONFIG_FILE_REGEX } from './filenames'; 16 | import { existsSync, readFileSync, statSync } from 'node:fs'; 17 | 18 | /** 19 | * Loads an ESModule-style config file. 20 | */ 21 | const esmLoader = async ( 22 | filename: string, 23 | code: string 24 | ): Promise => { 25 | const configModule = await loadConfigFromBundledFile(filename, code); 26 | 27 | validateConfigStructure(configModule); 28 | 29 | return configModule; 30 | }; 31 | 32 | async function loadConfigFromBundledFile( 33 | fileName: string, 34 | bundledCode: string 35 | ): Promise { 36 | // swiped a bit from https://github.com/vitejs/vite/blob/main/packages/vite/src/node/config.ts 37 | // 38 | // for esm, before we can register loaders without requiring users to run node 39 | // with --experimental-loader themselves, we have to do a hack here: 40 | // write it to disk, load it with native Node ESM, then delete the file. 41 | const fileBase = `${fileName}.timestamp-${Date.now()}`; 42 | const fileNameTmp = `${fileBase}.mjs`; 43 | const fileUrl = `${pathToFileURL(fileBase)}.mjs`; 44 | await fs.writeFile(fileNameTmp, bundledCode); 45 | 46 | try { 47 | const configModule = await import(fileUrl); 48 | validateConfigHasDefaultExport(configModule); 49 | 50 | return configModule.default; 51 | } finally { 52 | await fs.unlink(fileNameTmp); 53 | } 54 | } 55 | 56 | /** 57 | * Pulls the user config from an optionally specified filepath. 58 | * 59 | * By default, this will search the current working directory. 60 | * 61 | * @param options options for loading the config file, defaults to `{}`. Can pass in `cwd` as a path `string` to override the current working directory. 62 | * @returns Promise that resolves to the user config object. 63 | */ 64 | export async function loadConfig({ cwd = process.cwd() } = {}): Promise< 65 | ConfigResult 66 | > { 67 | let configFileName: ConfigFileName; 68 | const files = await fs.readdir(cwd); 69 | 70 | const matchingFiles = files.filter((file) => 71 | FLATBREAD_CONFIG_FILE_REGEX.test(file) 72 | ) as ConfigFileName[]; 73 | 74 | if (matchingFiles.length > 1) { 75 | throw new TooManyConfigsFoundError(matchingFiles); 76 | } else if (matchingFiles.length === 1) { 77 | // Grab the config file name declared in the user's project root 78 | configFileName = matchingFiles[0]; 79 | } else { 80 | throw new NoConfigFoundError(); 81 | } 82 | 83 | const configFilePath = path.join(cwd, configFileName); 84 | const { code } = await bundleConfigFile(cwd, configFileName); 85 | const rawConfig = await esmLoader(configFileName, code); 86 | const config = initializeConfig(rawConfig); 87 | 88 | return { 89 | filepath: configFilePath, 90 | config: config, 91 | }; 92 | } 93 | 94 | /** 95 | * Bundle the config file with esbuild and return the code. 96 | * @param fileName config file name 97 | * @returns 98 | */ 99 | async function bundleConfigFile( 100 | cwd: string, 101 | fileName: string 102 | ): Promise<{ code: string }> { 103 | const configBuild = await build({ 104 | absWorkingDir: cwd, 105 | entryPoints: [fileName], 106 | write: false, 107 | platform: 'node', 108 | bundle: true, 109 | format: 'esm', 110 | sourcemap: 'inline', 111 | sourcesContent: false, 112 | metafile: true, 113 | banner: { 114 | // Workaround for "Dynamic require of "os" is not supported" issue https://github.com/evanw/esbuild/issues/1921 115 | js: "import { createRequire } from 'module';const require = createRequire(import.meta.url);", 116 | }, 117 | outExtension: { 118 | '.js': '.mjs', 119 | }, 120 | watch: true, 121 | plugins: [ 122 | { 123 | // 124 | // This prevents esbuild from bundling node-modules particularly to avoid an issue when bundling dependencies that puke when loaded in an ESM scope, even if shimmed. 125 | // Thank u for the solution to this, Vite :) 126 | // 127 | name: 'externalize-deps', 128 | setup(build) { 129 | build.onResolve({ filter: /.*/ }, ({ path: id, importer }) => { 130 | // 131 | // Externalize bare imports (ex. `import { createSvImgField } from '@flatbread/resolver-svimg'`) 132 | // 133 | if (id[0] !== '.' && !path.isAbsolute(id)) { 134 | return { 135 | external: true, 136 | }; 137 | } 138 | // 139 | // Bundle the rest and make sure that we can also access 140 | // its third-party dependencies. 141 | // 142 | // Externalize if not. 143 | // 144 | // monorepo/ 145 | // ├─ package.json 146 | // ├─ utils.js -----------> bundle (share same node_modules) 147 | // ├─ flatbread-project/ 148 | // │ ├─ flatbread.config.js --> entry 149 | // │ ├─ package.json 150 | // ├─ anotha-project/ 151 | // │ ├─ utils.js --------> external (has own node_modules) 152 | // │ ├─ package.json 153 | // 154 | const idFsPath = path.resolve(path.dirname(importer), id); 155 | const idPkgPath = lookupFile(idFsPath, [`package.json`], { 156 | pathOnly: true, 157 | }); 158 | if (idPkgPath) { 159 | const idPkgDir = path.dirname(idPkgPath); 160 | // if this file needs to go up one or more directory to reach the flatbread config, 161 | // that means it has it's own node_modules (e.g. `anotha-project` in the above graph) 162 | if (path.relative(idPkgDir, fileName).startsWith('..')) { 163 | return { 164 | // normalize actual import after bundled as a single flatbread config 165 | path: pathToFileURL(idFsPath).href, 166 | external: true, 167 | }; 168 | } 169 | } 170 | }); 171 | }, 172 | }, 173 | ], 174 | }); 175 | 176 | const { text } = configBuild?.outputFiles[0]; 177 | 178 | return { 179 | code: text, 180 | }; 181 | } 182 | 183 | interface LookupFileOptions { 184 | pathOnly?: boolean; 185 | rootDir?: string; 186 | } 187 | 188 | /** 189 | * Lookup a file in a directory, diving into subdirectories until it is found. 190 | */ 191 | function lookupFile( 192 | dir: string, 193 | formats: string[], 194 | options?: LookupFileOptions 195 | ): string | undefined { 196 | for (const format of formats) { 197 | const fullPath = path.join(dir, format); 198 | if (existsSync(fullPath) && statSync(fullPath).isFile()) { 199 | return options?.pathOnly ? fullPath : readFileSync(fullPath, 'utf-8'); 200 | } 201 | } 202 | const parentDir = path.dirname(dir); 203 | if ( 204 | parentDir !== dir && 205 | (!options?.rootDir || parentDir.startsWith(options?.rootDir)) 206 | ) { 207 | return lookupFile(parentDir, formats, options); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /packages/config/src/validate.ts: -------------------------------------------------------------------------------- 1 | import { FlatbreadConfig } from '@flatbread/core'; 2 | 3 | type ESModule = Record & { default: Record }; 4 | 5 | /** 6 | * Validate that the user config has a default export that is an object. 7 | * 8 | * @param config user config 9 | * @returns user config 10 | */ 11 | export function isConfigAnESM(config: unknown): config is ESModule { 12 | return typeof config === 'object' && config !== null && 'default' in config; 13 | } 14 | 15 | export function validateConfigHasDefaultExport(config: unknown): ESModule { 16 | if (isConfigAnESM(config)) { 17 | return config; 18 | } 19 | 20 | throw new Error( 21 | 'The Flatbread config file must be an ESModule that exports the config object as its default property.' 22 | ); 23 | } 24 | 25 | /** 26 | * Validate that the user config has `source` and `content` properties. 27 | * 28 | * @todo More thoroughly validate that the config is valid with joi and throw errors if not 29 | * @param config user config object 30 | * @returns user config object 31 | */ 32 | export function validateConfigStructure( 33 | config: Record 34 | ): config is FlatbreadConfig { 35 | if (!('source' in config) || typeof config.source !== 'object') { 36 | throw new Error( 37 | 'Your Flatbread config is missing a valid "source" property. Make sure to include a Flatbread-compatible source plugin, such as @flatbread/source-filesystem' 38 | ); 39 | } 40 | 41 | if (!('transformer' in config)) { 42 | throw new Error( 43 | `Your Flatbread config is missing a valid "transformer" property. Make sure to include a Flatbread-compatible transformer, such as @flatbread/transformer-markdown` 44 | ); 45 | } 46 | 47 | if (!('content' in config) || !Array.isArray(config.content)) { 48 | throw new Error( 49 | 'Your Flatbread config is missing a valid "content" property.' 50 | ); 51 | } 52 | 53 | return true; 54 | } 55 | -------------------------------------------------------------------------------- /packages/config/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: false, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/index.ts'], 8 | format: ['esm', 'cjs'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | treeshake: true, 13 | banner: { 14 | js: ` 15 | import { createRequire } from 'module'; 16 | import { resolve } from 'path'; 17 | 18 | const require = createRequire(resolve(import.meta.url));`, 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /packages/core/README.md: -------------------------------------------------------------------------------- 1 | # @flatbread/core 🍶 2 | 3 | The internal GraphQL schema generator for Flatbread. This runs the plugins declared in the user's config, pulling in content and transforming it prior to generating the GraphQL schema. 4 | 5 | As a general user of Flatbread, you likely want to use the full [Flatbread module](https://www.npmjs.com/package/flatbread) 6 | 7 | However, you can utilize this package directly to build your own custom GraphQL server via installing: 8 | 9 | ```bash 10 | pnpm i @flatbread/core@latest 11 | ``` 12 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/core", 3 | "version": "1.0.0-alpha.15", 4 | "description": "Flatbread's essential logic", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsup", 8 | "dev": "tsup --watch src" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 13 | "directory": "packages/core" 14 | }, 15 | "homepage": "https://github.com/FlatbreadLabs/flatbread/tree/main/packages/core#readme", 16 | "author": "Tony Ketcham ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 20 | }, 21 | "exports": { 22 | ".": "./dist/index.js" 23 | }, 24 | "main": "dist/index.js", 25 | "module": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "files": [ 28 | "dist", 29 | "*.d.ts" 30 | ], 31 | "dependencies": { 32 | "graphql": "16.5.0", 33 | "graphql-compose": "9.0.8", 34 | "graphql-compose-json": "6.2.0", 35 | "lodash-es": "4.17.21", 36 | "lru-cache": "7.13.2", 37 | "matcher": "5.0.0", 38 | "plur": "5.1.0" 39 | }, 40 | "devDependencies": { 41 | "@types/lodash-es": "4.17.6", 42 | "@types/node": "16.11.47", 43 | "tsup": "6.2.1", 44 | "typescript": "4.7.4", 45 | "vfile": "5.3.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/core/src/cache/cache.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLSchema } from 'graphql'; 2 | import LRU from 'lru-cache'; 3 | import { createHash } from 'node:crypto'; 4 | import { LoadedFlatbreadConfig } from '../types'; 5 | import { anyToString } from '../utils/stringUtils'; 6 | 7 | type SchemaCacheKey = string; 8 | 9 | interface FlatbreadCache { 10 | schema: LRU; 11 | } 12 | 13 | /** 14 | * A general cache for computationally heavy operations in Flatbread. 15 | */ 16 | export const cache: FlatbreadCache = { 17 | /** 18 | * An LRU cache for GraphQL schemas generated by Flatbread. 19 | */ 20 | schema: new LRU({ 21 | max: 100, 22 | }), 23 | }; 24 | 25 | /** 26 | * Setter function for caching the GraphQL schema generated by Flatbread. 27 | */ 28 | export function cacheSchema( 29 | config: LoadedFlatbreadConfig, 30 | schema: GraphQLSchema 31 | ) { 32 | const schemaHashKey = getSchemaHash(config); 33 | cache.schema.set(schemaHashKey, schema); 34 | } 35 | 36 | /** 37 | * Getter function for retrieving the GraphQL schema generated by Flatbread for a given config. 38 | */ 39 | export function checkCacheForSchema( 40 | config: LoadedFlatbreadConfig 41 | ): GraphQLSchema | undefined { 42 | const schemaHashKey = getSchemaHash(config); 43 | return cache.schema.get(schemaHashKey); 44 | } 45 | 46 | /** 47 | * Generates a hash key for a given Flatbread config. 48 | */ 49 | export function getSchemaHash(config: LoadedFlatbreadConfig) { 50 | return createHash('md5').update(anyToString(config)).digest('hex'); 51 | } 52 | -------------------------------------------------------------------------------- /packages/core/src/errors.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from './utils/outdent'; 2 | 3 | export class IllegalFieldNameError extends Error { 4 | constructor(illegalSequence: string) { 5 | super(); 6 | this.message = outdent` 7 | The sequence "${illegalSequence}" is reserved and not allowed in field names 8 | Either: 9 | - remove all instances of "${illegalSequence}" in the names of fields in your content 10 | - add a fieldNameTransform function to your flatbread.config.js to translate to something else 11 | Example: 12 | { 13 | ..., 14 | fieldNameTransform: (value) => value.replaceAll("${illegalSequence}",'-') 15 | } 16 | `; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/generators/arguments.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates the accepted arguments for an 'All' query on a content type. 3 | * 4 | * @param pluralType plural name of the content type 5 | */ 6 | export const generateArgsForAllItemQuery = (pluralType: string) => ({ 7 | ...skip(), 8 | ...limit(pluralType), 9 | ...order(pluralType, 'ASC'), 10 | ...sortBy(pluralType), 11 | ...filter(pluralType), 12 | }); 13 | 14 | /** 15 | * Generates the accepted arguments for a 'many-item' query on a content type. 16 | * 17 | * @param pluralType plural name of the content type 18 | */ 19 | export const generateArgsForManyItemQuery = (pluralType: string) => ({ 20 | ids: { 21 | type: '[String]', 22 | }, 23 | ...skip(), 24 | ...limit(pluralType), 25 | ...order(pluralType, 'ASC'), 26 | ...sortBy(pluralType), 27 | }); 28 | 29 | /** 30 | * Generates the accepted arguments for a 'single-item' query on a content type. 31 | * 32 | */ 33 | export const generateArgsForSingleItemQuery = () => ({ 34 | id: { 35 | type: 'String', 36 | }, 37 | }); 38 | 39 | /** 40 | * Argument for skipping the first `n` items from the query results. 41 | */ 42 | export const skip = () => ({ 43 | skip: { 44 | description: 'Skip the first `n` results', 45 | type: 'Int', 46 | }, 47 | }); 48 | 49 | /** 50 | * Argument for limiting the maximum number of items in the query results. 51 | * 52 | * @param pluralType plural name of the content type 53 | */ 54 | export const limit = (pluralType: string) => ({ 55 | limit: { 56 | description: `The maximum number of ${pluralType} to return`, 57 | type: 'Int', 58 | }, 59 | }); 60 | 61 | /** 62 | * Argument for ordering the direction of sorting items in the query results. 63 | * 64 | * @param pluralType plural name of the content type 65 | * @param defaultValue default order to use if not explicitly specified in the query 66 | */ 67 | export const order = ( 68 | pluralType: string, 69 | defaultValue: 'ASC' | 'DESC' = 'ASC' 70 | ) => ({ 71 | order: { 72 | description: `Which order to return ${pluralType} in`, 73 | type: `enum Order { ASC DESC }`, 74 | defaultValue, 75 | }, 76 | }); 77 | 78 | /** 79 | * Argument for the field to sort items by in the query results. 80 | * 81 | * @param pluralType plural name of the content type 82 | */ 83 | export const sortBy = (pluralType: string) => ({ 84 | sortBy: { 85 | description: `The field to sort ${pluralType} by`, 86 | type: 'String', 87 | }, 88 | }); 89 | 90 | /** 91 | * Argument for the deep filter to apply to items in the query results. 92 | * 93 | * @param pluralType plural name of the content type 94 | */ 95 | export const filter = (pluralType: string) => ({ 96 | filter: { 97 | description: `Filter ${pluralType} by a JSON object`, 98 | type: 'JSON', 99 | }, 100 | }); 101 | -------------------------------------------------------------------------------- /packages/core/src/generators/generateCollection.ts: -------------------------------------------------------------------------------- 1 | import { defaultsDeep, merge } from 'lodash-es'; 2 | import { LoadedFlatbreadConfig } from '../types'; 3 | import { getFieldOverrides } from '../utils/fieldOverrides'; 4 | import transformKeys from '../utils/transformKeys'; 5 | 6 | interface GenerateCollectionArgs { 7 | collection: string; 8 | nodes: T[]; 9 | config: LoadedFlatbreadConfig; 10 | preknownSchemaFragments: Record; 11 | } 12 | 13 | export function generateCollection({ 14 | collection, 15 | preknownSchemaFragments, 16 | config, 17 | nodes, 18 | }: GenerateCollectionArgs) { 19 | return transformKeys( 20 | defaultsDeep( 21 | {}, 22 | getFieldOverrides(collection, config), 23 | ...nodes.map((node) => merge({}, node, preknownSchemaFragments)) 24 | ), 25 | config.fieldNameTransform 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { generateSchema } from './generators/schema'; 2 | export { initializeConfig } from './utils/initializeConfig'; 3 | 4 | export * from './types'; 5 | export { FlatbreadProvider } from './providers/base'; 6 | -------------------------------------------------------------------------------- /packages/core/src/providers/base.ts: -------------------------------------------------------------------------------- 1 | import { generateSchema } from '../generators/schema'; 2 | import { FlatbreadConfig } from '../types'; 3 | import { initializeConfig } from '../utils/initializeConfig'; 4 | 5 | import { graphql, GraphQLArgs, GraphQLSchema } from 'graphql'; 6 | 7 | /** 8 | * **Flatbread Provider** 9 | * 10 | * Create a new Flatbread provider which contains a GraphQL `query` function that's baked with a Flatbread config 11 | */ 12 | export class FlatbreadProvider { 13 | private schemaPromise: Promise; 14 | 15 | constructor(config: FlatbreadConfig) { 16 | const initializedConfig = initializeConfig(config); 17 | this.schemaPromise = generateSchema({ config: initializedConfig }); 18 | } 19 | 20 | /** 21 | * Fulfils GraphQL operations by parsing, validating, and executing a GraphQL document along side a Flatbread-generated GraphQL schema. 22 | * 23 | * @param args GraphQLArgs needed for executing a query. Typically, this is just a standard GraphQL query. 24 | * @returns GraphQL response 25 | */ 26 | async query(args: Omit) { 27 | const schema = await this.schemaPromise; 28 | return await graphql({ schema, ...args }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/core/src/providers/test/base.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import filesystem from '@flatbread/source-filesystem'; 3 | import markdownTransformer from '@flatbread/transformer-markdown'; 4 | import { FlatbreadProvider } from '../base'; 5 | 6 | function basicProject() { 7 | return new FlatbreadProvider({ 8 | source: filesystem(), 9 | transformer: markdownTransformer({ 10 | markdown: { 11 | gfm: true, 12 | externalLinks: true, 13 | }, 14 | }), 15 | 16 | content: [ 17 | { 18 | path: 'examples/content/markdown/authors', 19 | collection: 'Author', 20 | refs: { 21 | friend: 'Author', 22 | }, 23 | }, 24 | ], 25 | }); 26 | } 27 | 28 | test('basic query', async (t) => { 29 | const flatbread = basicProject(); 30 | 31 | const result = await flatbread.query({ 32 | source: ` 33 | query AllAuthors { 34 | allAuthors { 35 | name 36 | enjoys 37 | } 38 | } 39 | `, 40 | }); 41 | 42 | t.snapshot(result); 43 | }); 44 | 45 | test('relational filter query', async (t) => { 46 | const flatbread = basicProject(); 47 | 48 | const result = await flatbread.query({ 49 | source: ` 50 | query AllAuthors { 51 | allAuthors(filter: {friend: {name: {eq: "Eva"}}}) { 52 | name 53 | enjoys 54 | } 55 | } 56 | `, 57 | }); 58 | 59 | t.snapshot(result); 60 | }); 61 | -------------------------------------------------------------------------------- /packages/core/src/providers/test/snapshots/base.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/core/src/providers/test/base.test.ts` 2 | 3 | The actual snapshot is saved in `base.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## basic query 8 | 9 | > Snapshot 1 10 | 11 | { 12 | data: { 13 | allAuthors: [ 14 | { 15 | enjoys: [ 16 | 'cats', 17 | 'coffee', 18 | 'design', 19 | ], 20 | name: 'Daes', 21 | }, 22 | { 23 | enjoys: [ 24 | 'sitting', 25 | 'standing', 26 | 'mow mow', 27 | 'sleepy time', 28 | 'attention', 29 | ], 30 | name: 'Eva', 31 | }, 32 | { 33 | enjoys: [ 34 | 'cats', 35 | 'tea', 36 | 'making this', 37 | ], 38 | name: 'Tony', 39 | }, 40 | { 41 | enjoys: [ 42 | 'peeing in the cat tower', 43 | 'being shaped like an egg', 44 | 'violence', 45 | ], 46 | name: 'Ushi', 47 | }, 48 | { 49 | enjoys: [ 50 | 'talking', 51 | 'encroaching upon personal space', 52 | 'being concerned', 53 | 'smooth jazz', 54 | ], 55 | name: 'Yoshi', 56 | }, 57 | ], 58 | }, 59 | } 60 | 61 | ## relational filter query 62 | 63 | > Snapshot 1 64 | 65 | { 66 | data: { 67 | allAuthors: [ 68 | { 69 | enjoys: [ 70 | 'cats', 71 | 'coffee', 72 | 'design', 73 | ], 74 | name: 'Daes', 75 | }, 76 | { 77 | enjoys: [ 78 | 'cats', 79 | 'tea', 80 | 'making this', 81 | ], 82 | name: 'Tony', 83 | }, 84 | ], 85 | }, 86 | } 87 | -------------------------------------------------------------------------------- /packages/core/src/providers/test/snapshots/base.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/packages/core/src/providers/test/snapshots/base.test.ts.snap -------------------------------------------------------------------------------- /packages/core/src/resolvers/arguments.ts: -------------------------------------------------------------------------------- 1 | import { keyBy } from 'lodash-es'; 2 | import sift, { 3 | generateFilterSetManifest, 4 | TargetAndComparator, 5 | } from '../utils/sift'; 6 | import { ContentNode, FlatbreadConfig } from '../types'; 7 | import { FlatbreadProvider } from '../providers/base'; 8 | interface ResolveQueryArgsOptions { 9 | type: { 10 | name: string; 11 | pluralName: string; 12 | pluralQueryName: string; 13 | }; 14 | } 15 | 16 | /** 17 | * Resolvers for query arguments. 18 | */ 19 | const resolveQueryArgs = async ( 20 | nodes: any[], 21 | args: any, 22 | config: FlatbreadConfig, 23 | options: ResolveQueryArgsOptions 24 | ) => { 25 | const { skip, limit, order, sortBy, filter } = args; 26 | 27 | if (filter) { 28 | // Place the nodes into a keyed object by ID so we can easily filter by ID without doing tons of looping. 29 | // TODO: store all nodes in an ID-keyed object. 30 | // TODO: replace id field with user-defined/fallback identifier field. 31 | const nodeById = keyBy(nodes, 'id'); 32 | 33 | // Turn the filter into a GraphQL subquery that returns an array of matching content node IDs. 34 | const listOfNodeIDsToFilter = await resolveFilter(filter, config, options); 35 | 36 | nodes = listOfNodeIDsToFilter.map( 37 | (desiredNodeId) => nodeById[desiredNodeId] 38 | ); 39 | } 40 | 41 | if (sortBy) { 42 | resolveSortBy(sortBy, nodes); 43 | } 44 | 45 | if (order === 'DESC') { 46 | nodes.reverse(); 47 | } 48 | 49 | return nodes.slice(skip ?? 0, limit ?? undefined); 50 | }; 51 | 52 | /** 53 | * Builds a GraphQL query fragment from a `filterSetManifest`. 54 | * This is useful for building a GraphQL query which resolves a set of content nodes which include the fields specified in the filter set. 55 | * That result can then be used to filter the nodes by the filter argument in the `sift` function, obtaining a list of matching nodes. 56 | * 57 | * @example 58 | * // Consider this query with a complex filter: 59 | * query AllPosts { 60 | * allPosts(filter: {_content: {timeToRead: {gte: 0}}, title: {wildcard: "Test*"}}) { 61 | * id 62 | * title 63 | * } 64 | * } 65 | * 66 | * // The filter is converted to: 67 | * filterSetManifest = [ 68 | * { 69 | * path: [ '_content', 'timeToRead' ], 70 | * comparator: { operation: 'gte', value: 0 } 71 | * }, 72 | * { 73 | * path: [ 'title' ], 74 | * comparator: { operation: 'wildcard', value: 'Test*' } 75 | * } 76 | * ] 77 | * 78 | * // ...which is passed into this function to then become this fragment: 79 | * _content { 80 | * timeToRead 81 | * } 82 | * title 83 | * 84 | */ 85 | function buildFilterQueryFragment(filterSetManifest: TargetAndComparator) { 86 | let filterToQuery = []; 87 | 88 | for (const filter of filterSetManifest) { 89 | let graphQLFieldAccessor = ''; 90 | 91 | for (let i = 0; i < filter.path.length; i++) { 92 | const field = filter.path[i]; 93 | const lastFieldIndex = filter.path.length - 1; 94 | 95 | // Build a partial GraphQL query's field shape 96 | if (i === lastFieldIndex && filter.path.length === 1) { 97 | // If the filter path is a leaf-field, just add the field name. 98 | graphQLFieldAccessor += `${field}`; 99 | } else if (i !== lastFieldIndex) { 100 | // If the filter is not a leaf-field, we need to add the field name and open it to contain child fields. 101 | graphQLFieldAccessor += `${field} {`; 102 | } else { 103 | // If the current field is the last leaf-field of a nested accessor, we need to add the field name and close it with the same number of opening brackets it took to reach this depth. 104 | graphQLFieldAccessor += `${field} ${[lastFieldIndex] 105 | .map(() => '}') 106 | .join('')}`; 107 | } 108 | } 109 | 110 | filterToQuery.push(graphQLFieldAccessor); 111 | } 112 | 113 | return filterToQuery.join('\n'); 114 | } 115 | 116 | /** 117 | * Deeply resolves a filter argument as a subquery, and returns a set of content node IDs that satisfy the filter. 118 | * 119 | * @param filter the filter argument 120 | */ 121 | export const resolveFilter = async ( 122 | filter: Record, 123 | config: FlatbreadConfig, 124 | options: ResolveQueryArgsOptions 125 | ): Promise<(string | number)[]> => { 126 | // Seperate the filter into its parts: 127 | // - the path leading to the field we want to compare 128 | // - the comparator expression. 129 | const filterSetManifest = generateFilterSetManifest(filter); 130 | 131 | // Run Flatbread as a function to execute a subquery 132 | const flatbread = new FlatbreadProvider(config); 133 | 134 | // Build a GraphQL query fragment that will be used to resolve content nodes in a structure expected by the sift function, for the given filter. 135 | const filterQueryFragment = buildFilterQueryFragment(filterSetManifest); 136 | 137 | // TODO: replace id field with user-defined/fallback identifier field 138 | const queryString = ` 139 | query ${options.type.pluralQueryName}_FilterSubquery { 140 | ${options.type.pluralQueryName} { 141 | id 142 | ${filterQueryFragment} 143 | } 144 | } 145 | `; 146 | 147 | const { data } = await flatbread.query({ 148 | source: queryString, 149 | }); 150 | 151 | const result = data?.[options.type.pluralQueryName] as ContentNode[]; 152 | 153 | return result.filter(sift(filter)).map((node) => node.id); 154 | }; 155 | 156 | /** 157 | * Mutably sort a list of nodes by a given field. 158 | * 159 | * @param sortBy the field to sort by 160 | * @param nodes the array of nodes to sort 161 | */ 162 | export const resolveSortBy = (sortBy: string, nodes: any[]): void => { 163 | nodes.sort((nodeA: { [x: string]: any }, nodeB: { [x: string]: any }) => { 164 | const fieldA = nodeA[sortBy]; 165 | const fieldB = nodeB[sortBy]; 166 | 167 | if (fieldA < fieldB) { 168 | return -1; 169 | } 170 | if (fieldA > fieldB) { 171 | return 1; 172 | } 173 | // fields must be equal 174 | return 0; 175 | }); 176 | }; 177 | 178 | export default resolveQueryArgs; 179 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLFieldConfigArgumentMap, GraphQLInputType } from 'graphql'; 2 | import { Maybe } from 'graphql/jsutils/Maybe'; 3 | import type { VFile } from 'vfile'; 4 | 5 | export type IdentifierField = string | number; 6 | 7 | /** 8 | * A JSON representation of a content node. 9 | */ 10 | export type BaseContentNode = { 11 | id: IdentifierField; 12 | }; 13 | 14 | export type ContentNode = BaseContentNode & { 15 | [key: string]: unknown; 16 | }; 17 | 18 | /** 19 | * Flatbread's configuration interface. 20 | * 21 | * @todo This needs to be typed more strictly. 22 | */ 23 | export interface FlatbreadConfig { 24 | source: Source; 25 | transformer?: Transformer | Transformer[]; 26 | content: Content; 27 | fieldNameTransform?: (field: string) => string; 28 | } 29 | 30 | export interface LoadedFlatbreadConfig { 31 | source: Source; 32 | transformer: Transformer[]; 33 | content: Content; 34 | fieldNameTransform: (field: string) => string; 35 | loaded: { 36 | extensions: string[]; 37 | }; 38 | } 39 | 40 | export interface ConfigResult { 41 | filepath?: string; 42 | config?: O; 43 | } 44 | 45 | /** 46 | * Converts input to meaningful data. 47 | * To be used as a helper layer on top of a source that is not directly usable. 48 | * For example, a markdown file. 49 | */ 50 | export interface Transformer { 51 | /** 52 | * Parse a given source file into its contained data fields and an unnormalized representation of the content. 53 | * @param input Node to transform 54 | */ 55 | parse?: (input: VFile) => EntryNode; 56 | preknownSchemaFragments?: () => Record; 57 | inspect: (input: EntryNode) => string; 58 | extensions: string[]; 59 | } 60 | 61 | export type TransformerPlugin = (config?: Config) => Transformer; 62 | 63 | /** 64 | * A representation of the content of a flat file. 65 | */ 66 | export type EntryNode = Record; 67 | 68 | /** 69 | * The result of an invoked `Source` plugin which contains methods on how to retrieve content nodes in 70 | * their raw (if coupled with a `Transformer` plugin) or processed form. 71 | */ 72 | export interface Source { 73 | initialize?: (flatbreadConfig: LoadedFlatbreadConfig) => void; 74 | fetchByType?: (path: string) => Promise; 75 | fetch: ( 76 | allContentTypes: Record[] 77 | ) => Promise>; 78 | } 79 | 80 | export type SourcePlugin = (sourceConfig?: Record) => Source; 81 | 82 | /** 83 | * An override can be used to declare a custom resolve for a field in content 84 | */ 85 | // derived from GraphQLFieldConfig 86 | export interface Override { 87 | field: string; 88 | type: GraphQLInputType | string; 89 | args?: GraphQLFieldConfigArgumentMap; 90 | description?: Maybe; 91 | resolve: ( 92 | data: any, 93 | extended: { source: any; context: any; args: any } 94 | ) => any; 95 | } 96 | 97 | /** 98 | * An array of content descriptions which can be used to retrieve content nodes. 99 | * 100 | * This is paired with a `Source` (and, *optionally*, a `Transformer`) plugin. 101 | */ 102 | export type Content = { 103 | collection: string; 104 | overrides?: Override[]; 105 | [key: string]: any; 106 | }[]; 107 | -------------------------------------------------------------------------------- /packages/core/src/utils/arrayUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Wraps a value within an array if it is not an array. 3 | */ 4 | export function toArray(value: T | T[]): T[] { 5 | return Array.isArray(value) ? value : [value]; 6 | } 7 | -------------------------------------------------------------------------------- /packages/core/src/utils/camelCase.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * camelCase string 3 | * @param field: string 4 | * @returns camelCaseString 5 | */ 6 | export default function camelCase(field: string) { 7 | return String(field).replace(/\s(\w)/g, (_, m) => m.toUpperCase()); 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/utils/deepEntries.ts: -------------------------------------------------------------------------------- 1 | import typeOf from './typeOf'; 2 | 3 | /** 4 | * Recursively transforms a nested object into a multidimensional array of length-2, where the first item is an array of keys leading to the value and the second item is the value found at the end of the path. 5 | * 6 | * @param obj any Javascript object 7 | * @param path the path to a value in the object 8 | * @param stack a tuple with a path array and value which that path leads to 9 | * @returns a tuple with a path array and value which that path leads to 10 | */ 11 | const deepEntries = ( 12 | obj: Record, 13 | path: string[] = [], 14 | stack: any[] = [] 15 | ): [string[], any] => { 16 | if (typeOf(obj) === 'object') { 17 | for (let [key, value] of Object.entries(obj)) { 18 | stack = deepEntries(value, [...path, key], stack); 19 | } 20 | } else { 21 | stack.push([path, obj]); 22 | } 23 | return stack as [string[], any]; 24 | }; 25 | 26 | export default deepEntries; 27 | -------------------------------------------------------------------------------- /packages/core/src/utils/fieldOverrides.ts: -------------------------------------------------------------------------------- 1 | import { FlatbreadConfig, Override } from '../types'; 2 | import { get, set } from 'lodash-es'; 3 | 4 | /** 5 | * Get an object containing functions nested in an object structure 6 | * aligning to the listed overrides in the config 7 | * 8 | * @param collection the collection string referenced in the config 9 | * @param config the flatbread config object 10 | * @returns an object in the shape of the json schema 11 | */ 12 | export function getFieldOverrides(collection: string, config: FlatbreadConfig) { 13 | const content = config.content.find( 14 | (content) => content.collection === collection 15 | ); 16 | if (!content?.overrides) return {}; 17 | const overrides = content.overrides; 18 | 19 | return overrides.reduce((fields: any, override: Override) => { 20 | const { field, type, ...rest } = override; 21 | let path = field.replace(/\[\]/g, '[0]'); 22 | const endsWithArray = path.endsWith('[0]'); 23 | 24 | if (endsWithArray) path = path.slice(0, -3); 25 | 26 | const getPath = path.split(/(?:\.|\[0\])/).at(-1) as string; 27 | set(fields, path, () => ({ 28 | type: endsWithArray ? `[${override.type}]` : override.type, 29 | ...rest, 30 | resolve: (source: any, context: any, args: any) => { 31 | return override.resolve(get(source, getPath), { 32 | source, 33 | context, 34 | args, 35 | }); 36 | }, 37 | })); 38 | return fields; 39 | }, {}); 40 | } 41 | -------------------------------------------------------------------------------- /packages/core/src/utils/initializeConfig.ts: -------------------------------------------------------------------------------- 1 | import { cloneDeep } from 'lodash-es'; 2 | import { FlatbreadConfig, LoadedFlatbreadConfig, Transformer } from '../types'; 3 | import { toArray } from './arrayUtils'; 4 | import camelCase from './camelCase'; 5 | 6 | /** 7 | * Processes a config object and returns a normalized version of it. 8 | */ 9 | export function initializeConfig( 10 | rawConfig: FlatbreadConfig 11 | ): LoadedFlatbreadConfig { 12 | const config = cloneDeep(rawConfig); 13 | const transformer = toArray(config.transformer ?? []); 14 | 15 | return { 16 | fieldNameTransform: camelCase, 17 | ...config, 18 | transformer, 19 | loaded: { 20 | extensions: transformer 21 | .map((transformer: Transformer) => transformer.extensions || []) 22 | .flat(), 23 | }, 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /packages/core/src/utils/map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A generic, functional utility for mapping over an object of keyed-arrays. 3 | * 4 | * @param keyedArray The object of keyed-arrays to map over. 5 | * @param callback The callback to call for each keyed-array. 6 | * @returns The object of keyed-arrays with the callback applied. 7 | */ 8 | export function map( 9 | keyedArray: KeyedArray, 10 | callback: (node: A) => B 11 | ) { 12 | return Object.fromEntries( 13 | Object.entries(keyedArray).map(([key, array]) => [ 14 | key, 15 | array.map((node: A): B => callback(node)), 16 | ]) 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /packages/core/src/utils/objectUtils.ts: -------------------------------------------------------------------------------- 1 | export function isObject( 2 | value: unknown 3 | ): value is Record { 4 | return ( 5 | !!value && typeof value === 'object' && value.constructor.name === 'Object' 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /packages/core/src/utils/outdent.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * when used as a template literal tag will remove the indentation that exists from the code block indentation 3 | * 4 | */ 5 | 6 | export function outdent( 7 | strings: TemplateStringsArray, 8 | ...values: string[] 9 | ): string { 10 | const result = strings 11 | .map((s, i) => s + (values[i] ?? '')) 12 | .join('') 13 | .trim(); 14 | const matches = Array.from(result.matchAll(/\n(\s+)/gs)); 15 | const minimumIndent = 16 | matches.map((match) => match[1]).sort((a, b) => a.length - b.length)?.[0] 17 | ?.length ?? 0; 18 | const replacer = new RegExp(`\\n\\s{${minimumIndent}}`, 'g'); 19 | 20 | return result.replace(replacer, '\n'); 21 | } 22 | -------------------------------------------------------------------------------- /packages/core/src/utils/reduceBooleans.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Union an array of booleans to a single boolean with the given union type. 3 | * 4 | * @param array The array of booleans to reduce. 5 | * @param unionType 6 | * @returns The union boolean. 7 | */ 8 | const reduceBooleans = ( 9 | array: Required, 10 | unionType: Required<'and' | 'or'> 11 | ): boolean => { 12 | if (unionType === 'and') { 13 | return array.reduce((acc, curr) => acc && curr, true); 14 | } else if (unionType === 'or') { 15 | return array.reduce((acc, curr) => acc || curr, false); 16 | } 17 | throw new Error( 18 | `Unsupported reduceBooleans() union type given: ${unionType}` 19 | ); 20 | }; 21 | 22 | export default reduceBooleans; 23 | -------------------------------------------------------------------------------- /packages/core/src/utils/sift.ts: -------------------------------------------------------------------------------- 1 | import { EntryNode } from '../types'; 2 | import { get } from 'lodash-es'; 3 | import deepEntries from './deepEntries'; 4 | import reduceBooleans from './reduceBooleans'; 5 | import { isMatch as isWildcardMatch } from 'matcher'; 6 | 7 | /** 8 | * Return a callable sifting function that can be used to filter an array of objects with the given filter object. 9 | * 10 | * The generated function accepts a single object and returns a boolean. 11 | * 12 | * @param filterArgs The filter object. 13 | * @returns A callable sift function. 14 | */ 15 | const createFilterFunction = ( 16 | filterArgs: Readonly, 17 | filterSetManifest?: TargetAndComparator 18 | ) => { 19 | return (node: EntryNode) => { 20 | // If there are no filter args, return the original array. 21 | if (!filterArgs) { 22 | return node; 23 | } 24 | 25 | // If a filter set manifest is not given, generate one 26 | // Filter args transformed to logical expressions. 27 | filterSetManifest ??= generateFilterSetManifest(filterArgs); 28 | 29 | let evaluatedFilterSet: boolean[] = []; 30 | 31 | for (let { path, comparator } of filterSetManifest) { 32 | // Retrieve the value of interest from the node. 33 | const needle = get(node, path, undefined); 34 | // Compare the value of interest to the target value, and store the result of the evaluated expression. 35 | evaluatedFilterSet.push(generateComparisonFunction(comparator)(needle)); 36 | } 37 | 38 | // Combine the filter set results with the union operation. 39 | return reduceBooleans(evaluatedFilterSet, 'and'); 40 | }; 41 | }; 42 | export default createFilterFunction; 43 | 44 | /** 45 | * Generate a comparison function that can be used to compare a variable `a` (the field in each node) to a constant value `value` (target value in filter argument). 46 | * 47 | * @param comparator The comparator object that contains the operation and the target value. 48 | * @returns A function that can be used to compare a value to the target value. 49 | */ 50 | function generateComparisonFunction( 51 | comparator: Comparator 52 | ): CompareValueAgainstConstant { 53 | const { operation, value } = comparator; 54 | switch (operation) { 55 | case 'eq': 56 | return (a: any) => a === value; 57 | case 'ne': 58 | return (a: any) => a !== value; 59 | case 'lt': 60 | return (a: any) => a < value; 61 | case 'lte': 62 | return (a: any) => a <= value; 63 | case 'gt': 64 | return (a: any) => a > value; 65 | case 'gte': 66 | return (a: any) => a >= value; 67 | case 'in': 68 | return (a: any) => value.includes(a); 69 | case 'nin': 70 | return (a: any) => !value.includes(a); 71 | case 'includes': 72 | return (a: any) => a.includes(value); 73 | case 'excludes': 74 | return (a: any) => !a.includes(value); 75 | case 'regex': 76 | return (a: any) => value.test(a); 77 | case 'wildcard': 78 | return (a: any) => isWildcardMatch(a, value); 79 | case 'exists': 80 | return (a: any) => (value ? a != undefined : a == undefined); 81 | case 'strictlyExists': 82 | return (a: any) => (value ? a !== undefined : a === undefined); 83 | default: 84 | throw new Error(`Unsupported operation: ${operation}`); 85 | } 86 | } 87 | 88 | /** 89 | * Seperate the filter args into an array of target and comparator objects. 90 | * 91 | * @param filterArgs The filter argument object. 92 | * @returns 93 | */ 94 | export const generateFilterSetManifest = ( 95 | filterArgs: SiftArgs 96 | ): TargetAndComparator => { 97 | return deepEntries(filterArgs).map(([path, value]) => { 98 | const operation = path.pop(); 99 | 100 | return { 101 | path, 102 | comparator: { 103 | operation, 104 | value, 105 | }, 106 | }; 107 | }); 108 | }; 109 | 110 | /** 111 | * The filter argument object using a MongoDB-like syntax, inspired by how Gatsby does it. 112 | * 113 | * @see [Gatsby's query filters](https://github.com/gatsbyjs/gatsby/blob/d56c1f12ad2b3e7fa245f4ff9a74e81d0585b79e/docs/docs/query-filters.md) for API details. 114 | */ 115 | type SiftArgs = Record; 116 | 117 | /** 118 | * An array of target and comparator objects 119 | */ 120 | export type TargetAndComparator = { path: string[]; comparator: Comparator }[]; 121 | 122 | /** 123 | * Consists of a comparison operation label and the value to compare against. 124 | */ 125 | type Comparator = { 126 | operation: ComparatorOperation; 127 | value: any; 128 | }; 129 | 130 | /** 131 | * Supported comparison operations: 132 | * 133 | * @example 134 | * ``` 135 | * 'eq' - Equal 136 | * 'ne' - Not equal 137 | * 'lt' - Less than 138 | * 'lte' - Less than or equal 139 | * 'gt' - Greater than 140 | * 'gte' - Greater than or equal 141 | * 'in' - In 142 | * 'nin' - Not in 143 | * 'includes' - Includes in array field 144 | * 'excludes' - Excludes from array field 145 | * 'regex' - Regular expression 146 | * 'wildcard' - loose string matching 147 | * 'exists' - Exists (checks against `undefined | null`) 148 | * 'strictlyExists' - Strictly exists (checks against `undefined`) 149 | * ``` 150 | */ 151 | type ComparatorOperation = 152 | | 'eq' 153 | | 'ne' 154 | | 'lt' 155 | | 'lte' 156 | | 'gt' 157 | | 'gte' 158 | | 'in' 159 | | 'nin' 160 | | 'includes' 161 | | 'excludes' 162 | | 'regex' 163 | | 'wildcard' 164 | | 'exists' 165 | | 'strictlyExists'; 166 | 167 | /** 168 | * Compare a value to a constant target value. 169 | */ 170 | type CompareValueAgainstConstant = (a: any) => boolean; 171 | -------------------------------------------------------------------------------- /packages/core/src/utils/stringUtils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts any value to a string. Stringifies functions, RegExp, and objects for hashing. 3 | * 4 | * @param valueToConvert The value to convert 5 | */ 6 | export function anyToString(valueToConvert: unknown): string { 7 | return JSON.stringify(valueToConvert, replaceAnyToString); 8 | } 9 | 10 | /** 11 | * Replacer function for `JSON.stringify` that converts functions, RegExp, and objects to strings. 12 | */ 13 | export function replaceAnyToString(_: string, value: unknown) { 14 | if (value === undefined) { 15 | return 'undefined'; 16 | } 17 | 18 | return typeof value === 'function' || 19 | (value instanceof RegExp && value.constructor === RegExp) 20 | ? value.toString() 21 | : value; 22 | } 23 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/fieldOverrides.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { getFieldOverrides } from '../fieldOverrides.js'; 3 | 4 | function getProps(overrides: any[]): [string, any] { 5 | return ['t', { content: [{ collection: 't', overrides }] }]; 6 | } 7 | 8 | test('basic override', (t) => { 9 | const result = getFieldOverrides( 10 | ...getProps([ 11 | { 12 | field: 'basic', 13 | type: 'string', 14 | resolve: (source: string) => { 15 | t.is(source, 'test'); 16 | return true; 17 | }, 18 | }, 19 | ]) 20 | ); 21 | t.snapshot(result); 22 | t.is(result.basic().resolve({ basic: 'test' }), true); 23 | }); 24 | 25 | test('nested basic override', (t) => { 26 | const result = getFieldOverrides( 27 | ...getProps([ 28 | { 29 | field: 'nested.basic', 30 | type: 'string', 31 | resolve: (source: string) => { 32 | t.is(source, 'test'); 33 | return true; 34 | }, 35 | }, 36 | ]) 37 | ); 38 | t.snapshot(result); 39 | t.is(result.nested.basic().resolve({ basic: 'test' }), true); 40 | }); 41 | 42 | test('basic array override', (t) => { 43 | const result = getFieldOverrides( 44 | ...getProps([ 45 | { 46 | field: 'basic[]', 47 | type: 'string', 48 | resolve: (items: any) => { 49 | t.deepEqual(items, ['']); 50 | return items.map(() => true); 51 | }, 52 | }, 53 | ]) 54 | ); 55 | t.snapshot(result); 56 | t.deepEqual(result.basic().resolve({ basic: [''] }), [true]); 57 | }); 58 | 59 | test('basic object array override', (t) => { 60 | const result = getFieldOverrides( 61 | ...getProps([ 62 | { 63 | field: 'basic[]obj', 64 | type: 'string', 65 | resolve: (source: string) => { 66 | t.is(source, 'test'); 67 | return true; 68 | }, 69 | }, 70 | ]) 71 | ); 72 | t.snapshot(result); 73 | t.deepEqual(result.basic[0].obj().resolve({ obj: 'test' }), true); 74 | }); 75 | 76 | test('override with custom type', (t) => { 77 | const result = getFieldOverrides( 78 | ...getProps([ 79 | { 80 | field: 'basic', 81 | type: `type FlatbreadImage { src: String alt: String }`, 82 | resolve: (source: string) => { 83 | t.is(source, 'test'); 84 | return true; 85 | }, 86 | }, 87 | ]) 88 | ); 89 | t.snapshot(result); 90 | t.deepEqual(result.basic().resolve({ basic: 'test' }), true); 91 | }); 92 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/outdent.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { outdent } from '../outdent'; 3 | 4 | test('basic outdent', (t) => { 5 | const result = outdent`This 6 | is 7 | a 8 | test you 9 | this should still be indented`; 10 | 11 | t.is( 12 | result, 13 | `This 14 | is 15 | a 16 | test you 17 | this should still be indented` 18 | ); 19 | }); 20 | 21 | test('it still interpolates data', (t) => { 22 | const p = 'animal'; 23 | const result = outdent`This 24 | is 25 | a 26 | test you ${p} 27 | this should still be indented`; 28 | 29 | t.is( 30 | result, 31 | `This 32 | is 33 | a 34 | test you animal 35 | this should still be indented` 36 | ); 37 | }); 38 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/sift.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import sift from '../sift'; 3 | 4 | const nodes = [ 5 | { id: 1, name: 'foo', child: { id: 2, name: 'bar', age: 44 } }, 6 | { id: 3, name: 'baz', child: { id: 4, name: 'qux', age: 9 } }, 7 | { id: 5, name: 'quux', child: { id: 6, name: 'quuz', age: 18 } }, 8 | ]; 9 | 10 | test('Sifting an empty array returns an empty array', (t) => { 11 | t.deepEqual([].filter(sift({ id: { eq: 1 } })), []); 12 | }); 13 | 14 | test('Sifting with empty filter args returns the unfiltered nodes', (t) => { 15 | t.deepEqual(nodes.filter(sift({})), nodes); 16 | }); 17 | 18 | test('Sift for nodes with name equal to "foo"', (t) => { 19 | t.deepEqual(nodes.filter(sift({ name: { eq: 'foo' } })), [nodes[0]]); 20 | }); 21 | 22 | test('Sift for nodes with nested object "child" having age greater than or equal to 18', (t) => { 23 | t.deepEqual(nodes.filter(sift({ child: { age: { gte: 18 } } })), [ 24 | nodes[0], 25 | nodes[2], 26 | ]); 27 | }); 28 | 29 | test('Union sift for nodes with id greater than 1 and nested object "child" having age greater than or equal to 18', (t) => { 30 | t.deepEqual( 31 | nodes.filter( 32 | sift({ 33 | id: { 34 | gt: 1, 35 | }, 36 | child: { 37 | age: { 38 | gte: 18, 39 | }, 40 | }, 41 | }) 42 | ), 43 | [nodes[2]] 44 | ); 45 | }); 46 | 47 | const nodes2 = [ 48 | { id: 1, title: 'My pretzel collection', postMeta: { rating: 97 } }, 49 | { id: 2, title: 'Debugging the simulation', postMeta: { rating: 20 } }, 50 | { 51 | id: 3, 52 | title: 'Liquid Proust is a great tea vendor btw', 53 | postMeta: { rating: 99 }, 54 | }, 55 | { id: 4, title: 'Sitting in a chair', postMeta: { rating: 74 } }, 56 | ]; 57 | 58 | test('Sift by regex where title contains "pretzel"', (t) => { 59 | t.deepEqual(nodes2.filter(sift({ title: { regex: /pretzel/i } })), [ 60 | nodes2[0], 61 | ]); 62 | }); 63 | 64 | test('Union sift for nodes with wildcard title matching "*tion", rating greater than 80', (t) => { 65 | t.deepEqual( 66 | nodes2.filter( 67 | sift({ title: { wildcard: '*tion' }, postMeta: { rating: { gt: 80 } } }) 68 | ), 69 | [nodes2[0]] 70 | ); 71 | }); 72 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/snapshots/fieldOverrides.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/core/src/utils/tests/fieldOverrides.test.ts` 2 | 3 | The actual snapshot is saved in `fieldOverrides.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## basic override 8 | 9 | > Snapshot 1 10 | 11 | { 12 | basic: Function {}, 13 | } 14 | 15 | ## nested basic override 16 | 17 | > Snapshot 1 18 | 19 | { 20 | nested: { 21 | basic: Function {}, 22 | }, 23 | } 24 | 25 | ## basic array override 26 | 27 | > Snapshot 1 28 | 29 | { 30 | basic: Function {}, 31 | } 32 | 33 | ## basic object array override 34 | 35 | > Snapshot 1 36 | 37 | { 38 | basic: [ 39 | { 40 | obj: Function {}, 41 | }, 42 | ], 43 | } 44 | 45 | ## override with custom type 46 | 47 | > Snapshot 1 48 | 49 | { 50 | basic: Function {}, 51 | } 52 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/snapshots/fieldOverrides.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/packages/core/src/utils/tests/snapshots/fieldOverrides.test.ts.snap -------------------------------------------------------------------------------- /packages/core/src/utils/tests/snapshots/transformKeys.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/core/src/utils/tests/transformKeys.test.ts` 2 | 3 | The actual snapshot is saved in `transformKeys.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## it throws an error on illegal field names 8 | 9 | > Snapshot 1 10 | 11 | `The sequence "[]" is reserved and not allowed in field names␊ 12 | Either:␊ 13 | - remove all instances of "[]" in the names of fields in your content␊ 14 | - add a fieldTransform function to your flatbread.config.js to translate to something else␊ 15 | Example:␊ 16 | {␊ 17 | ...,␊ 18 | fieldTransform: (value) => value.replaceAll("[]",'-')␊ 19 | }` 20 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/snapshots/transformKeys.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/packages/core/src/utils/tests/snapshots/transformKeys.test.ts.snap -------------------------------------------------------------------------------- /packages/core/src/utils/tests/transformKeys.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { camelCase } from 'lodash-es'; 3 | import transformKeys from '../transformKeys'; 4 | 5 | test('it works for basic entries', (t) => { 6 | t.deepEqual( 7 | transformKeys({ 'this is a test': true, blah: 'blah' }, camelCase), 8 | { 9 | thisIsATest: true, 10 | blah: 'blah', 11 | } 12 | ); 13 | 14 | t.deepEqual( 15 | transformKeys({ 'this is a test': [1, 2, 3, 4], blah: 'blah' }, camelCase), 16 | { 17 | thisIsATest: [1, 2, 3, 4], 18 | blah: 'blah', 19 | } 20 | ); 21 | }); 22 | 23 | test('it works with arrays', (t) => { 24 | t.deepEqual( 25 | transformKeys({ 'this is a test': [1, 2, 3, 4], blah: 'blah' }, camelCase), 26 | { 27 | thisIsATest: [1, 2, 3, 4], 28 | blah: 'blah', 29 | } 30 | ); 31 | }); 32 | 33 | test('it works with undescores', (t) => { 34 | t.deepEqual( 35 | transformKeys({ this_is_a_test: [1, 2, 3, 4], blah: 'blah' }, camelCase), 36 | { 37 | thisIsATest: [1, 2, 3, 4], 38 | blah: 'blah', 39 | } 40 | ); 41 | }); 42 | 43 | test('it works with dates', (t) => { 44 | t.deepEqual( 45 | transformKeys( 46 | { this_is_a_test: new Date('2021-02-25T16:41:59.558Z'), blah: 'blah' }, 47 | camelCase 48 | ), 49 | { 50 | thisIsATest: new Date('2021-02-25T16:41:59.558Z'), 51 | blah: 'blah', 52 | } 53 | ); 54 | }); 55 | 56 | test('it throws an error on illegal field names', (t) => { 57 | const error = t.throws(() => 58 | transformKeys( 59 | { 60 | 'thisisatest[]': test, 61 | }, 62 | (s) => s 63 | ) 64 | ); 65 | t.snapshot(error?.message); 66 | }); 67 | -------------------------------------------------------------------------------- /packages/core/src/utils/tests/typeOf.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import typeOf from '../typeOf'; 3 | 4 | test('Nullish types return specific type', (t) => { 5 | t.is(typeOf(null), 'null'); 6 | t.is(typeOf({}), 'object'); 7 | t.is(typeOf(), 'undefined'); 8 | t.is(typeOf(''), 'string'); 9 | }); 10 | 11 | test('Function is not an object', (t) => { 12 | t.not( 13 | typeOf(() => console.log("I'm a function!")), 14 | 'object' 15 | ); 16 | }); 17 | 18 | test('Date is not an object', (t) => { 19 | t.not(typeOf(new Date()), 'object'); 20 | }); 21 | -------------------------------------------------------------------------------- /packages/core/src/utils/transformKeys.ts: -------------------------------------------------------------------------------- 1 | import { outdent } from './outdent'; 2 | 3 | class IllegalFieldNameError extends Error { 4 | constructor(illegalSequence: string) { 5 | super(); 6 | this.message = outdent` 7 | The sequence "${illegalSequence}" is reserved and not allowed in field names 8 | Either: 9 | - remove all instances of "${illegalSequence}" in the names of fields in your content 10 | - add a fieldTransform function to your flatbread.config.js to translate to something else 11 | Example: 12 | { 13 | ..., 14 | fieldTransform: (value) => value.replaceAll("${illegalSequence}",'-') 15 | } 16 | `; 17 | } 18 | } 19 | 20 | function isObject(obj: any): obj is Object { 21 | return obj != null && obj.constructor.name === 'Object'; 22 | } 23 | 24 | export default function transformKeys( 25 | obj: any, 26 | transform: (key: string) => string 27 | ): any { 28 | if (Array.isArray(obj)) 29 | return obj.map((item) => transformKeys(item, transform)); 30 | if (!isObject(obj)) return obj; 31 | return Object.fromEntries( 32 | Object.entries(obj).map(([key, value]) => { 33 | const newKey = transform(key); 34 | if (newKey.includes('[]')) throw new IllegalFieldNameError('[]'); 35 | return [newKey, transformKeys(value, transform)]; 36 | }) 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /packages/core/src/utils/typeOf.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A more advanced typeof wrapper that resolves primitive types like `object` into their true types. 3 | * 4 | * @param obj The object to resolve. 5 | * @returns The true type of the object, or `undefined` if it can't be resolved. 6 | */ 7 | export default function typeOf(obj?: T) { 8 | if (obj == null) { 9 | return (obj + '').toLowerCase(); 10 | } // implicit toString() conversion 11 | 12 | var deepType = Object.prototype.toString.call(obj).slice(8, -1).toLowerCase(); 13 | if (deepType === 'generatorfunction') { 14 | return 'function'; 15 | } 16 | 17 | // Prevent overspecificity (for example, [object HTMLDivElement], etc). 18 | // Account for functionish Regexp (Android <=2.3), functionish element (Chrome <=57, Firefox <=52), etc. 19 | // String.prototype.match is universally supported. 20 | 21 | return deepType.match( 22 | /^(array|bigint|date|error|function|generator|regexp|symbol)$/ 23 | ) 24 | ? deepType 25 | : typeof obj === 'object' || typeof obj === 'function' 26 | ? 'object' 27 | : typeof obj; 28 | } 29 | -------------------------------------------------------------------------------- /packages/core/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: false, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/index.ts'], 8 | format: ['esm'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | treeshake: true, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/flatbread/bin/flatbread.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { resolve } from 'path'; 3 | import { existsSync } from 'fs'; 4 | 5 | // In CI/CD, check if the file exists before importing it. This is to prevent some environments from throwing an error before the library is built. 6 | if (process.env.FLATBREAD_CI) { 7 | const cliPath = resolve( 8 | process.cwd(), 9 | 'node_modules', 10 | 'flatbread', 11 | 'dist', 12 | 'cli', 13 | 'index.js' 14 | ); 15 | 16 | if (existsSync(cliPath)) { 17 | import('../dist/cli/index.js'); 18 | } else { 19 | console.log("Flatbread's CLI is not available"); 20 | } 21 | } else { 22 | import('../dist/cli/index.js'); 23 | } 24 | -------------------------------------------------------------------------------- /packages/flatbread/content/authors/me copy.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Another User 3 | id: 435r 4 | enjoys: 5 | - apples 6 | date_joined: 2021-02-25T16:41:59.558Z 7 | skills: 8 | sitting: 204 9 | breathing: 7.07 10 | liquid_consumption: 100 11 | existence: simulation 12 | sports: -2 13 | cat_pat: 1500 14 | --- 15 | -------------------------------------------------------------------------------- /packages/flatbread/content/authors/me.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Tony 3 | id: 2a3e 4 | friend: 435r 5 | enjoys: 6 | - cats 7 | - tea 8 | - making this 9 | date_joined: 2021-02-25T16:41:59.558Z 10 | skills: 11 | sitting: 204 12 | breathing: 7.07 13 | liquid_consumption: 100 14 | existence: simulation 15 | sports: -2 16 | cat_pat: 1500 17 | --- 18 | -------------------------------------------------------------------------------- /packages/flatbread/content/books/edpost_realprogrammersdontusepascal.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Real Programmers Don't Use Pascal 3 | author: Ed Post 4 | --- 5 | -------------------------------------------------------------------------------- /packages/flatbread/content/books/gendertrouble_butler.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: 'Gender Trouble: Feminism and the Subversion of Identity' 3 | author: Judith Butler 4 | --- 5 | -------------------------------------------------------------------------------- /packages/flatbread/content/books/test_dirty_file.mrakdn: -------------------------------------------------------------------------------- 1 | --- 2 | title: The Plague 3 | autho r: Albert Camus 4 | 5 | -?- 6 | 7 | const hello = 'whoops!'; -------------------------------------------------------------------------------- /packages/flatbread/content/books/theplague_camus.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: The Plague 3 | author: Albert Camus 4 | --- 5 | -------------------------------------------------------------------------------- /packages/flatbread/content/books/winnerstakeall_anandgiridharas.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Winners Take All 3 | author: Anand Giridharadas 4 | --- 5 | -------------------------------------------------------------------------------- /packages/flatbread/content/posts/anotha-one.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: 92348fds-453fdh-59ddsd-3332-09876 3 | title: 'Senior cat list' 4 | author: '2a3e' 5 | rating: 100 6 | --- 7 | 8 | **all of them are good** 9 | -------------------------------------------------------------------------------- /packages/flatbread/content/posts/example-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | id: sdfsdf-23423-sdfsd-23444-dfghf 3 | title: 'Example post of things' 4 | author: '2a3e' 5 | rating: 74 6 | --- 7 | 8 | # My cat is so little 9 | 10 | ## she is so small 11 | 12 | ### she is in fact 13 | 14 | 2 apples tall 15 | 16 | # My cat is so little 17 | 18 | ## she is so small 19 | 20 | ### she is in fact 21 | 22 | 2 apples tall 23 | 24 | [hello](https://karmalies.studio) 25 | 26 | ```javascript 27 | function fancyAlert(arg) { 28 | if (arg) { 29 | $.facebox({ div: '#foo' }); 30 | } 31 | } 32 | ``` 33 | 34 | ~~this~~ 35 | 36 | | First Header | Second Header | 37 | | --------------------------- | ---------------------------- | 38 | | Content from cell 1 | Content from cell 2 | 39 | | Content in the first column | Content in the second column | 40 | -------------------------------------------------------------------------------- /packages/flatbread/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flatbread", 3 | "version": "1.0.0-alpha.20", 4 | "description": "Consume relational, flat-file data using GraphQL 🥯 inside damn near any framework.", 5 | "type": "module", 6 | "scripts": { 7 | "build": "tsup", 8 | "dev": "tsup --watch src" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 13 | "directory": "packages/flatbread" 14 | }, 15 | "homepage": "https://github.com/FlatbreadLabs/flatbread#readme", 16 | "author": "Tony Ketcham ", 17 | "license": "MIT", 18 | "bugs": { 19 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 20 | }, 21 | "exports": { 22 | ".": "./dist/index.js" 23 | }, 24 | "main": "dist/index.js", 25 | "module": "dist/index.js", 26 | "types": "dist/index.d.ts", 27 | "bin": "bin/flatbread.js", 28 | "files": [ 29 | "bin", 30 | "dist", 31 | "*.d.ts" 32 | ], 33 | "dependencies": { 34 | "@apollo/utils.keyvaluecache": "^1.0.1", 35 | "@flatbread/config": "workspace:*", 36 | "@flatbread/core": "workspace:*", 37 | "@flatbread/source-filesystem": "workspace:*", 38 | "@flatbread/transformer-markdown": "workspace:*", 39 | "@flatbread/transformer-yaml": "workspace:*", 40 | "apollo-server-core": "^3.9.0", 41 | "apollo-server-express": "^3.9.0", 42 | "cors": "^2.8.5", 43 | "express": "^4.18.1", 44 | "express-graphql": "^0.12.0", 45 | "find-up": "^6.3.0", 46 | "gradient-string": "^2.0.1", 47 | "graphql": "16.5.0", 48 | "kleur": "^4.1.5", 49 | "remark-github": "^11.2.3", 50 | "sade": "^1.8.1", 51 | "serialize-javascript": "^6.0.0" 52 | }, 53 | "devDependencies": { 54 | "@types/cors": "2.8.12", 55 | "@types/express": "4.17.13", 56 | "@types/gradient-string": "1.1.2", 57 | "@types/node": "16.11.47", 58 | "@types/sade": "1.7.4", 59 | "@types/serialize-javascript": "5.0.2", 60 | "tsup": "6.2.1", 61 | "typescript": "4.7.4", 62 | "vfile": "5.3.4" 63 | }, 64 | "pnpm": { 65 | "peerDependencyRules": { 66 | "allowedVersions": { 67 | "graphql": "^16.0.1" 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/flatbread/src/cli/index.ts: -------------------------------------------------------------------------------- 1 | import sade from 'sade'; 2 | import colors from 'kleur'; 3 | import gradient from 'gradient-string'; 4 | import { version } from '../../package.json'; 5 | import { networkInterfaces, release } from 'node:os'; 6 | import orchestrateProcesses from './runner'; 7 | import initConfig from './initConfig'; 8 | 9 | const GRAPHQL_ENDPOINT = '/graphql'; 10 | 11 | /** 12 | * Launch the GraphQL explorer in a browser. 13 | * 14 | * Yoinked from [SvelteKit's CLI](https://github.com/sveltejs/kit/blob/2c133ff5b8798c885161ed57bfb45c88fc77f516/packages/kit/src/cli.js). 15 | * 16 | * @param {number} port the port the server is running on 17 | * @param {boolean} https whether the server is running on https 18 | */ 19 | async function launch(port: number, https: boolean): Promise { 20 | const { exec } = await import('child_process'); 21 | let cmd = 'open'; 22 | if (process.platform == 'win32') { 23 | cmd = 'start'; 24 | } else if (process.platform == 'linux') { 25 | if (/microsoft/i.test(release())) { 26 | cmd = 'cmd.exe /c start'; 27 | } else { 28 | cmd = 'xdg-open'; 29 | } 30 | } 31 | exec( 32 | `${cmd} ${https ? 'https' : 'http'}://localhost:${port + GRAPHQL_ENDPOINT}` 33 | ); 34 | } 35 | 36 | const prog = sade('flatbread').version(version); 37 | 38 | prog 39 | .command('start [corunner]', 'Start flatbread with a GraphQL server') 40 | .option('--, _', 'Pass options to the corunning script') 41 | .option('-p, --port', 'Port to run the GraphQL server', 5057) 42 | .option('-H, --https', 'Use self-signed HTTPS certificate', false) 43 | .option('-o, --open', 'Open the explorer in a browser tab', false) 44 | .option( 45 | '-X, --exec', 46 | 'The runner to execute the corunning script with. Defaults to your package manager (i.e. npm, pnpm, yarn)' 47 | ) 48 | .action(async (corunner, { _, port, https, open, exec }) => { 49 | // Combine the corunning script & the options passed to it 50 | const secondaryScript = `${corunner} ${_.join(' ')}`; 51 | // Yeet it into the all seeing eye of the universe 52 | orchestrateProcesses({ 53 | corunner: secondaryScript, 54 | flatbreadPort: port, 55 | packageManager: exec, 56 | }); 57 | // Say hi for good measure 58 | welcome({ port, https, open }); 59 | }); 60 | 61 | prog 62 | .command('init', 'Generate a flatbread.config.js file skeleton') 63 | .action(initConfig); 64 | 65 | prog.parse(process.argv, { unknown: (arg) => `Unknown option: ${arg}` }); 66 | 67 | /** 68 | * The welcome message for the user when starting the server. 69 | * 70 | * Yoinked from [SvelteKit's CLI](https://github.com/sveltejs/kit/blob/2c133ff5b8798c885161ed57bfb45c88fc77f516/packages/kit/src/cli.js) with some modifications. 71 | * 72 | * @param serverConfig server config object 73 | */ 74 | function welcome({ 75 | port, 76 | https, 77 | open, 78 | }: { 79 | open: boolean; 80 | https: boolean; 81 | port: number; 82 | }): void { 83 | if (open) launch(port, https); 84 | 85 | console.log( 86 | colors.bold( 87 | gradient.fruit('\n Flatbread 🥯') + gradient.vice(` v${version}\n`) 88 | ) 89 | ); 90 | 91 | const protocol = https ? 'https:' : 'http:'; 92 | 93 | Object.values(networkInterfaces()).forEach((interfaces) => { 94 | if (!interfaces) return; 95 | interfaces.forEach((details) => { 96 | if (details.family !== 'IPv4') return; 97 | 98 | if (details.internal) { 99 | console.log( 100 | ` ${colors.gray('graphql:')} ${protocol}//${colors.bold( 101 | `localhost:${port + GRAPHQL_ENDPOINT}` 102 | )}` 103 | ); 104 | } else { 105 | if (details.mac === '00:00:00:00:00:00') return; 106 | } 107 | }); 108 | }); 109 | 110 | console.log('\n'); 111 | } 112 | -------------------------------------------------------------------------------- /packages/flatbread/src/cli/initConfig.ts: -------------------------------------------------------------------------------- 1 | import colors from 'kleur'; 2 | import fs from 'fs'; 3 | import { resolve } from 'path'; 4 | 5 | /** 6 | * Initialize the Flatbread config file 7 | */ 8 | const initConfig = () => { 9 | const configFileName = 'flatbread.config.js'; 10 | const configPath = resolve(process.cwd(), configFileName); 11 | if (fs.existsSync(configPath)) { 12 | console.log(colors.red(`${configFileName} already exists`)); 13 | process.exit(1); 14 | } else { 15 | fs.writeFileSync( 16 | configPath, 17 | `import { defineConfig, transformerMarkdown, sourceFilesystem } from 'flatbread'; 18 | 19 | const transformerConfig = { 20 | markdown: { 21 | gfm: true, 22 | externalLinks: true, 23 | }, 24 | }; 25 | export default defineConfig({ 26 | source: sourceFilesystem(), 27 | transformer: transformerMarkdown(transformerConfig), 28 | 29 | content: [ 30 | { 31 | path: 'content/markdown/posts', 32 | collection: 'Post', 33 | refs: { 34 | authors: 'Author', 35 | }, 36 | }, 37 | { 38 | path: 'content/markdown/authors', 39 | collection: 'Author', 40 | refs: { 41 | friend: 'Author', 42 | }, 43 | }, 44 | ], 45 | }); 46 | ` 47 | ); 48 | console.log( 49 | colors.green( 50 | `\nGenerated a ${colors.cyan( 51 | colors.bold(configFileName) 52 | )} in your project root 🍞\n` 53 | ) 54 | ); 55 | console.log( 56 | colors.bold("Don't forget to replace your dev/build scripts with:"), 57 | colors.dim('\n\n"flatbread start -- "\n') 58 | ); 59 | } 60 | }; 61 | 62 | export default initConfig; 63 | -------------------------------------------------------------------------------- /packages/flatbread/src/cli/runner.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Delay the start of the target process until the GraphQL server is ready. 3 | * Learned about this thanks to: https://stackoverflow.com/a/48050020/12368615 4 | */ 5 | import { fork, spawn } from 'node:child_process'; 6 | import { basename, resolve } from 'node:path'; 7 | import { findUpSync } from 'find-up'; 8 | import colors from 'kleur'; 9 | 10 | export interface OrchestraOptions { 11 | corunner: string; 12 | flatbreadPort: number; 13 | packageManager: string | null; 14 | } 15 | 16 | /** 17 | * Block the target process until the GraphQL server is ready. 18 | * 19 | * Auto-detects your package manager and uses the appropriate command to invoke the target process. 20 | * 21 | * @param secondary Script to run after the GraphQL server is ready 22 | */ 23 | export default function orchestrateProcesses({ 24 | corunner, 25 | flatbreadPort, 26 | packageManager = null, 27 | }: OrchestraOptions) { 28 | const pkgManager = packageManager || detectPkgManager(process.cwd()); 29 | let serverModulePath = 'node_modules/flatbread/dist/graphql/server.js'; 30 | 31 | process.cwd(); 32 | const gql = fork(resolve(process.cwd(), serverModulePath), [''], { 33 | env: { 34 | ...process.env, 35 | NODE_OPTIONS: '--experimental-vm-modules', 36 | FLATBREAD_PORT: String(flatbreadPort), 37 | }, 38 | }); 39 | let runningScripts = [gql]; 40 | 41 | gql.on('message', (msg) => { 42 | if (msg !== 'flatbread-gql-ready') return; 43 | 44 | // Start the target process (e.g. the dev server or the build script) 45 | const targetProcess = spawn(pkgManager ?? 'npm run', [corunner], { 46 | shell: true, 47 | stdio: 'inherit', 48 | }); 49 | 50 | runningScripts.push(targetProcess); 51 | 52 | // Exit the parent process when the target process exits 53 | for (let script of runningScripts) { 54 | script.on('close', (code) => { 55 | // 56 | // If the target process exited with a non-zero `code`, exit the parent process with the same `code` 57 | // 58 | // If the target process closes with a null `code`, exit the parent process with an exit code of 1 59 | // (this usually indicates an error originating outside of the target process, where it is killed before it can exit) 60 | // 61 | // See https://nodejs.org/api/child_process.html#event-exit 62 | // 63 | process.exit(code ?? 1); 64 | }); 65 | } 66 | }); 67 | 68 | // End any remaining child processes when the parent process exits 69 | process.on('exit', (code) => { 70 | if (code === 0) { 71 | // Successfully exit 72 | console.log( 73 | colors.bold().green('\nFlatbread is done for now. Bye bye! 🥪') 74 | ); 75 | } else { 76 | // Exit with an error code 77 | console.log(colors.bold().red("\nFlatbread is feelin' moldy 🦠")); 78 | } 79 | 80 | runningScripts.forEach((child) => { 81 | child.kill(); 82 | }); 83 | }); 84 | } 85 | 86 | /** 87 | * Map of lock files to the command for running a script 88 | * with that respective package manager. 89 | */ 90 | const lockToRunner: Record = { 91 | 'pnpm-lock.yaml': 'pnpm', 92 | 'yarn.lock': 'yarn', 93 | 'package-lock.json': 'npm run', 94 | 'bun.lockb': 'bun run', 95 | }; 96 | 97 | /** 98 | * Detect the package manager used within the current working directory. 99 | * 100 | * Inspired by [@antfu/ni](https://github.com/antfu/ni) 101 | * 102 | * @param cwd The directory to search for a lock file 103 | * @returns The package manager command to run the script 104 | */ 105 | function detectPkgManager(cwd?: string) { 106 | const result = findUpSync(Object.keys(lockToRunner), { cwd }); 107 | return result ? lockToRunner[basename(result)] : null; 108 | } 109 | -------------------------------------------------------------------------------- /packages/flatbread/src/graphql/server.ts: -------------------------------------------------------------------------------- 1 | import { ApolloServer } from 'apollo-server-express'; 2 | import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core'; 3 | import { InMemoryLRUCache } from '@apollo/utils.keyvaluecache'; 4 | import express from 'express'; 5 | import http from 'http'; 6 | import { generateSchema } from '@flatbread/core'; 7 | import { getConfig } from '../utils/getSchema'; 8 | import { GraphQLSchema } from 'graphql'; 9 | 10 | const config = await getConfig(); 11 | const schema = await generateSchema(config); 12 | 13 | const port = Number(process.env.FLATBREAD_PORT) || 5050; 14 | 15 | startApolloServer(schema, { port }); 16 | 17 | async function startApolloServer(schema: GraphQLSchema, { port = 5050 } = {}) { 18 | const app = express(); 19 | const httpServer = http.createServer(app); 20 | const server = new ApolloServer({ 21 | schema, 22 | // Prevents an unbounded cache from growing infinitely and causing memory issues. 23 | cache: new InMemoryLRUCache({ 24 | // ~100MiB 25 | maxSize: Math.pow(2, 20) * 100, 26 | }), 27 | plugins: [ 28 | // Apollo Server will drain your HTTP server when you call the stop() method (which is also called for you when the SIGTERM and SIGINT signals are received) 29 | // @see https://www.apollographql.com/docs/apollo-server/api/plugin/drain-http-server/ 30 | ApolloServerPluginDrainHttpServer({ httpServer }), 31 | ], 32 | }); 33 | await server.start(); 34 | server.applyMiddleware({ app }); 35 | await new Promise((resolve) => httpServer.listen({ port }, resolve)); 36 | 37 | communicateReadiness(); 38 | } 39 | 40 | function communicateReadiness() { 41 | /** 42 | * If the process was spawned with an IPC channel, send a message to the parent process that the server is ready. 43 | * This allows the parent process to wait for the server to be ready before continuing, like when you want the GraphQL server to be ready before starting the build process. 44 | * @see https://nodejs.org/api/process.html#processsendmessage-sendhandle-options-callback 45 | */ 46 | process.send && process.send('flatbread-gql-ready'); 47 | } 48 | -------------------------------------------------------------------------------- /packages/flatbread/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@flatbread/core'; 2 | 3 | // Convenience exports for the most common use-cases 4 | export { default as defineConfig, loadConfig } from '@flatbread/config'; 5 | export { source as sourceFilesystem } from '@flatbread/source-filesystem'; 6 | export { transformer as transformerMarkdown } from '@flatbread/transformer-markdown'; 7 | export { transformer as transformerYaml } from '@flatbread/transformer-yaml'; 8 | -------------------------------------------------------------------------------- /packages/flatbread/src/utils/getSchema.ts: -------------------------------------------------------------------------------- 1 | import colors from 'kleur'; 2 | import type { ConfigResult, LoadedFlatbreadConfig } from '../'; 3 | 4 | /** 5 | * Wrapper around grabbing the user config and killing 6 | * the process if the config file is invalid. 7 | * 8 | * @returns user config promise 9 | */ 10 | export async function getConfig(): Promise< 11 | ConfigResult 12 | > { 13 | const { loadConfig } = await import('@flatbread/config'); 14 | 15 | try { 16 | return await loadConfig(); 17 | } catch (err) { 18 | // Provide a helpful error message if the config file is not found 19 | console.error( 20 | colors.red('\nFlatbread was not supplied a valid') + 21 | colors.bold(' config') + 22 | colors.red(' file.\n') 23 | ); 24 | console.error(err); 25 | process.exit(1); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/flatbread/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: true, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/cli/index.ts', 'src/index.ts', 'src/graphql/server.ts'], 8 | format: ['esm'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/resolver-svimg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/resolver-svimg", 3 | "version": "1.0.0-alpha.0", 4 | "description": "use svimg as a flatbread field resolver", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/tonyketcham/flatbread.git", 9 | "directory": "packages/resolver-svimg" 10 | }, 11 | "homepage": "https://github.com/tonyketcham/flatbread/tree/main/packages/resolver-svimg#readme", 12 | "author": "Adam Sparks ", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/tonyketcham/flatbread/issues" 16 | }, 17 | "exports": { 18 | ".": "./dist/index.js" 19 | }, 20 | "main": "dist/index.js", 21 | "module": "dist/index.js", 22 | "types": "dist/index.d.ts", 23 | "files": [ 24 | "dist", 25 | "*.d.ts" 26 | ], 27 | "scripts": { 28 | "build": "tsup", 29 | "dev": "tsup --watch src" 30 | }, 31 | "engines": { 32 | "node": "^14.13.1 || >=16.0.0" 33 | }, 34 | "peerDependencies": { 35 | "svimg": "^2.0.0 || ^3.0.0" 36 | }, 37 | "devDependencies": { 38 | "@flatbread/core": "workspace:*", 39 | "@types/node": "16.11.42", 40 | "svimg": "3.1.0", 41 | "tsup": "6.2.1", 42 | "typescript": "4.7.4" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/resolver-svimg/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Queue, generateComponentAttributes } from 'svimg/dist/process.js'; 2 | 3 | const queue = new Queue(); 4 | 5 | /** 6 | * `GenerateComponentAttributesOptions` from svimg 7 | */ 8 | type Config = Parameters[0]; 9 | 10 | const SVIMG_TYPE = ` 11 | """An Image Optimized by Svimg""" 12 | type Svimg { 13 | """Responsive images and widths""" 14 | srcset: String 15 | 16 | """Responsive WebP images and widths -- returns null if disabled via webp: false in config""" 17 | srcsetwebp: String 18 | 19 | """Responsive Avif images and widths -- returns null if disabled via avif: false in config""" 20 | srcsetavif: String 21 | 22 | """inline blurred placeholder image -- returns null if disabled via skipPlaceholder: true in config""" 23 | placeholder: String 24 | 25 | """Aspect ratio of image""" 26 | aspectratio: Float 27 | }`; 28 | 29 | /** 30 | * Resolves an image to an optimized, `svimg`-compatible set of attributes with optional image placeholders and fallbacks. 31 | * 32 | * @param field the field to override 33 | * @param config `GenerateComponentAttributesOptions` from svimg (without the `src` field) 34 | * @see https://github.com/xiphux/svimg/blob/master/src/component/generate-component-attributes.ts#L10 35 | */ 36 | export function createSvImgField(field: string, config: Omit) { 37 | return { 38 | field, 39 | type: SVIMG_TYPE, 40 | resolve(src: string) { 41 | if (!src) return null; 42 | return generateComponentAttributes({ queue, ...config, src }); 43 | }, 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /packages/resolver-svimg/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: false, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/*'], 8 | format: ['esm'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | treeshake: true, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/source-filesystem/README.md: -------------------------------------------------------------------------------- 1 | # @flatbread/source-filesystem 🗃 2 | 3 | > Transform files into content that can be fetched with GraphQL. 4 | 5 | ## 💾 Install 6 | 7 | Use `pnpm`, `npm`, or `yarn`: 8 | 9 | ```bash 10 | pnpm i @flatbread/source-filesystem 11 | ``` 12 | 13 | ## 👩‍🍳 Usage 14 | 15 | Add the source as a property of the default export within your `flatbread.config.js` file: 16 | 17 | ```js 18 | // flatbread.config.js 19 | import defineConfig from '@flatbread/config'; 20 | import transformer from '@flatbread/transformer-markdown'; 21 | import filesystem from '@flatbread/source-filesystem'; 22 | 23 | const transformerConfig = { 24 | markdown: { 25 | gfm: true, 26 | externalLinks: true, 27 | }, 28 | }; 29 | export default defineConfig({ 30 | source: filesystem(), 31 | transformer: transformer(transformerConfig), 32 | content: [ 33 | { 34 | path: 'content/posts', 35 | collection: 'Post', 36 | }, 37 | ], 38 | }); 39 | ``` 40 | 41 | A filesystem source will also require a transformer in order to parse the files into the proper internal schema. The example above is looking for a set of [Markdown](https://en.wikipedia.org/wiki/Markdown) files, so in order to let [Flatbread](https://github.com/FlatbreadLabs/flatbread) understand the content of markdown (.md, .markdown, .mdx) files, you must install [@flatbread/transformer-markdown](https://www.npmjs.com/package/@flatbread/transformer-markdown) as a dependency. Register the transformer which coresponds to your content filetype as the `transformer` property in your `flatbread.config.js`. 42 | 43 | ### Options 44 | 45 | #### content 46 | 47 | - Type: [`Content`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/core/src/types.ts) _required_ 48 | 49 | An array of content types - each of which will appear in GraphQL. 50 | 51 | #### collection 52 | 53 | - Type: `string` 54 | - Default: `'FileNode'` 55 | 56 | The name for this content type that will appear in GraphQL. 57 | 58 | #### path 59 | 60 | - Type: `string` _required_ 61 | 62 | Where to look for files of the current content type. 63 | 64 | - you can use `**` or `*` to match all files or folders 65 | - you can capture the file or folder names to store them as data on the resulting nodes `[category]` `[title].md` 66 | 67 | #### refs 68 | 69 | - Type: `object` 70 | 71 | Define fields that will have a reference to another node. The referenced `collection` is expected to exist within an element of the `content` array. 72 | 73 | ```js 74 | export default defineConfig({ 75 | source: filesystem(), 76 | transformer: transformer(transformerConfig), 77 | content: [ 78 | { 79 | path: 'content/posts', 80 | collection: 'Post', 81 | refs: { 82 | author: 'Author', 83 | }, 84 | }, 85 | { 86 | path: 'content/authors', 87 | collection: 'Author', 88 | }, 89 | ], 90 | }); 91 | ``` 92 | -------------------------------------------------------------------------------- /packages/source-filesystem/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/source-filesystem", 3 | "version": "1.0.0-alpha.8", 4 | "description": "Filesystem source for Flatbread", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 9 | "directory": "packages/source-filesystem" 10 | }, 11 | "homepage": "https://github.com/FlatbreadLabs/flatbread/tree/main/packages/source-filesystem#readme", 12 | "author": "Tony Ketcham ", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 16 | }, 17 | "exports": { 18 | ".": "./dist/index.js" 19 | }, 20 | "main": "dist/index.js", 21 | "module": "dist/index.js", 22 | "types": "dist/index.d.ts", 23 | "files": [ 24 | "dist", 25 | "*.d.ts" 26 | ], 27 | "scripts": { 28 | "build": "tsup", 29 | "dev": "tsup --watch src" 30 | }, 31 | "engines": { 32 | "node": "^14.13.1 || >=16.0.0" 33 | }, 34 | "dependencies": { 35 | "lodash-es": "^4.17.21", 36 | "to-vfile": "^7.2.3", 37 | "unified": "^10.1.2" 38 | }, 39 | "devDependencies": { 40 | "@flatbread/core": "workspace:*", 41 | "@types/lodash-es": "4.17.6", 42 | "@types/node": "16.11.47", 43 | "tsup": "6.2.1", 44 | "typescript": "4.7.4", 45 | "vfile": "5.3.4" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/index.ts: -------------------------------------------------------------------------------- 1 | import { defaultsDeep } from 'lodash-es'; 2 | import { read } from 'to-vfile'; 3 | 4 | import type { LoadedFlatbreadConfig, SourcePlugin } from '@flatbread/core'; 5 | import type { VFile } from 'vfile'; 6 | import type { 7 | FileNode, 8 | InitializedSourceFilesystemConfig, 9 | sourceFilesystemConfig, 10 | } from './types'; 11 | import gatherFileNodes from './utils/gatherFileNodes'; 12 | 13 | /** 14 | * Get nodes (files) from the directory 15 | * 16 | * @param path The directory to read from 17 | * @param config 'InitializedSourceFileSystemConfig 18 | * @returns An array of content nodes 19 | */ 20 | async function getNodesFromDirectory( 21 | path: string, 22 | config: InitializedSourceFilesystemConfig 23 | ): Promise { 24 | const { extensions } = config; 25 | const nodes: FileNode[] = await gatherFileNodes(path, { extensions }); 26 | 27 | return Promise.all( 28 | nodes.map(async (node: FileNode): Promise => { 29 | const file = await read(node.path); 30 | file.data = node.data; 31 | return file; 32 | }) 33 | ); 34 | } 35 | 36 | /** 37 | * Returns all nodes from the directory 38 | * 39 | * @param paths array of directories to read from 40 | * @returns 41 | */ 42 | async function getAllNodes( 43 | allContentTypes: Record[], 44 | config: InitializedSourceFilesystemConfig 45 | ): Promise> { 46 | const nodeEntries = await Promise.all( 47 | allContentTypes.map( 48 | async (contentType): Promise> => 49 | new Promise(async (res) => 50 | res([ 51 | contentType.collection, 52 | await getNodesFromDirectory(contentType.path, config), 53 | ]) 54 | ) 55 | ) 56 | ); 57 | 58 | const nodes = Object.fromEntries( 59 | nodeEntries as Iterable 60 | ); 61 | 62 | return nodes; 63 | } 64 | 65 | /** 66 | * Source filesystem plugin for fetching flat-file content nodes from directories on disk. 67 | * 68 | * @param sourceConfig content types config 69 | * @returns A function that returns functions which fetch lists of nodes 70 | */ 71 | export const source: SourcePlugin = (sourceConfig?: sourceFilesystemConfig) => { 72 | let config: InitializedSourceFilesystemConfig; 73 | 74 | return { 75 | initialize: (flatbreadConfig: LoadedFlatbreadConfig) => { 76 | const { extensions } = flatbreadConfig.loaded; 77 | config = defaultsDeep(sourceConfig ?? {}, { extensions }); 78 | }, 79 | fetchByType: (path: string) => getNodesFromDirectory(path, config), 80 | fetch: (allContentTypes: Record[]) => 81 | getAllNodes(allContentTypes, config), 82 | }; 83 | }; 84 | 85 | export default source; 86 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { Dirent } from 'node:fs'; 2 | 3 | /** 4 | * Config options for the source-filesystem plugin 5 | */ 6 | export interface sourceFilesystemConfig { 7 | /** 8 | * File extensions to include 9 | */ 10 | [key: string]: any; 11 | } 12 | 13 | export interface InitializedSourceFilesystemConfig 14 | extends sourceFilesystemConfig { 15 | extensions: string[]; 16 | } 17 | 18 | export interface FileNode extends Dirent { 19 | path: string; 20 | data: { 21 | [key: string]: any; 22 | }; 23 | } 24 | 25 | export interface GatherFileNodesOptions { 26 | extensions?: string[]; 27 | readDirectory?: (path: string) => Promise; 28 | } 29 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/utils/gatherFileNodes.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'node:fs/promises'; 2 | import { extname, join } from 'node:path'; 3 | import type { FileNode, GatherFileNodesOptions } from '../types'; 4 | 5 | type Segment = { name: string; remove: number } | null; 6 | 7 | async function readDir(path: string): Promise { 8 | const files = (await readdir(path, { withFileTypes: true })) as FileNode[]; 9 | 10 | return files.map((file) => { 11 | file.path = join(path, file.name); 12 | file.data = {}; 13 | return file; 14 | }); 15 | } 16 | 17 | function getSegmentData(node: { name: string }, segment: { remove: number }) { 18 | return node.name.slice(0, node.name.length - segment.remove); 19 | } 20 | 21 | function processFile(segment: Segment, node: FileNode) { 22 | return (file: FileNode) => { 23 | if (!segment) return file; 24 | file.data = { 25 | ...node.data, 26 | [segment.name]: getSegmentData(node, segment), 27 | }; 28 | return file; 29 | }; 30 | } 31 | 32 | /** 33 | * gather FileNodes in directories and sub-directories 34 | * according to glob patterns that match the allowed file extensions. 35 | * 36 | * @param path The directory to read from 37 | * @param options `GatherFileNodesOptions` 38 | * @returns FileNodes[] 39 | */ 40 | export default async function gatherFileNodes( 41 | path: string, 42 | { readDirectory = readDir, extensions }: GatherFileNodesOptions = {} 43 | ): Promise { 44 | /** 45 | * Prepend a period to the extension if it doesn't have one. 46 | * If no extensions are provided, use the default ones. 47 | * */ 48 | 49 | const formatValidExtensions = extensions?.map((ext) => 50 | String(ext).charAt(0) === '.' ? ext : `.${ext}` 51 | ) ?? ['.md', '.mdx', '.markdown']; 52 | 53 | // gather all the globs in the path ( [capture-groups], **, *) 54 | const [pathPrefix, ...globs] = path.split(/\/(?:\[|\*+)/); 55 | 56 | // for each segment - gather names for capture groups 57 | // and calculate what to remove from matches ex: [name].md => remove .md from match 58 | const segments = globs.map((branch) => { 59 | let index = branch.indexOf(']'); 60 | if (index === -1) return null; 61 | return { 62 | name: branch.slice(0, index), 63 | remove: branch.length - index - 1, 64 | }; 65 | }); 66 | 67 | let nodes = await readDirectory(join(process.cwd(), pathPrefix)); 68 | const leaf = segments.pop(); 69 | 70 | /** 71 | * For each directory segment 72 | * 1. step into the next level of each directory node 73 | * 2. collect the segment matches 74 | * 3. scan the new directories to create the nodes for the next segment 75 | */ 76 | for await (const segment of segments) { 77 | nodes = await Promise.all( 78 | nodes 79 | .filter((f) => f.isDirectory()) 80 | .map((node) => 81 | readDirectory(node.path).then((file) => 82 | file.map(processFile(segment, node)).flat() 83 | ) 84 | ) 85 | ).then((nodes) => nodes.flat()); 86 | } 87 | 88 | // now that all the nodes are files at the proper depth we can match the filenames 89 | // if they're [captured] 90 | if (leaf) { 91 | nodes = nodes.map((node) => { 92 | node.data[leaf.name] = getSegmentData(node, leaf); 93 | return node; 94 | }); 95 | } 96 | 97 | // throw away any node that doesn't match our extension allowlist 98 | return nodes.filter((n) => 99 | formatValidExtensions.includes(extname(n.path).toLowerCase()) 100 | ); 101 | } 102 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/utils/tests/gatherFileNodes.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import gatherFileNodes from '../gatherFileNodes'; 3 | import { readDirectory } from './mocks'; 4 | 5 | const dirStructure = { 6 | Comedy: ['Nine Lives of Tomas Katz, The.md', 'Road to Wellville, The.md'], 7 | Drama: ['Life for Sale (Life for Sale (Kotirauha).md', 'Lap Dance'], 8 | 9 | Documentary: ['TerrorStorm: A History of Government-Sponsored Terrorism.md'], 10 | 11 | 'random file.md': true, 12 | deeply: { 13 | nested: ['file.md'], 14 | }, 15 | }; 16 | 17 | const opts = { 18 | extensions: ['.md'], 19 | readDirectory: readDirectory(dirStructure), 20 | }; 21 | 22 | test('basic flat folder', async (t) => { 23 | const result = await gatherFileNodes('.', opts); 24 | t.snapshot(result); 25 | }); 26 | 27 | test('basic case', async (t) => { 28 | const result = await gatherFileNodes('deeply/nested', opts); 29 | t.snapshot(result); 30 | 31 | const result2 = await gatherFileNodes('./deeply/nested', opts); 32 | t.snapshot(result2); 33 | }); 34 | 35 | test('double level recursion', async (t) => { 36 | const result = await gatherFileNodes('deeply/**/*.md', opts); 37 | t.snapshot(result); 38 | }); 39 | 40 | test('double level recursion named', async (t) => { 41 | const result = await gatherFileNodes('deeply/[a]/[b].md', opts); 42 | t.snapshot(result); 43 | }); 44 | 45 | test('single level recursion', async (t) => { 46 | const result = await gatherFileNodes('./*.md', opts as any); 47 | t.snapshot(result); 48 | }); 49 | 50 | test('double level recursion named without parent directory', async (t) => { 51 | const result = await gatherFileNodes('./[genre]/[title].md', opts); 52 | t.snapshot(result); 53 | }); 54 | 55 | test('single level named', async (t) => { 56 | const result = await gatherFileNodes('./[title].md', opts); 57 | t.snapshot(result); 58 | }); 59 | 60 | test('double level first named', async (t) => { 61 | const result = await gatherFileNodes('./[genre]/*.md', opts); 62 | t.snapshot(result); 63 | }); 64 | 65 | test('double level second named', async (t) => { 66 | const result = await gatherFileNodes('./**/[title].md', opts); 67 | t.snapshot(result); 68 | }); 69 | 70 | test('triple level', async (t) => { 71 | const result = await gatherFileNodes('./[random]/[name]/[title].md', opts); 72 | t.snapshot(result); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/utils/tests/mocks.ts: -------------------------------------------------------------------------------- 1 | import type { FileNode } from '../../types'; 2 | 3 | export function readDirectory(dirStructure: any) { 4 | return async (path: string) => { 5 | const relativePath = path.replace(process.cwd(), '').replace(/^\//, ''); 6 | const nodes = relativePath.split('/'); 7 | let node = nodes.reduce((cur: any, next) => cur?.[next], dirStructure); 8 | if (relativePath === '') node = dirStructure; 9 | 10 | if (!node) return []; 11 | if (Array.isArray(node)) { 12 | return node.map((file: string) => ({ 13 | isDirectory: () => false, 14 | path: `${relativePath}/${file}`, 15 | name: file, 16 | data: {}, 17 | })) as unknown as FileNode[]; 18 | } 19 | 20 | if (typeof node === 'boolean') { 21 | console.error('tried read directory on file'); 22 | return []; 23 | } 24 | 25 | return Object.entries(node).map(([name, value]) => { 26 | return { 27 | isDirectory: () => typeof value !== 'boolean', 28 | path: `${relativePath}/${name}`, 29 | name, 30 | data: {}, 31 | }; 32 | }) as unknown as FileNode[]; 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/utils/tests/snapshots/gatherFileNodes.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/source-filesystem/src/utils/tests/gatherFileNodes.test.ts` 2 | 3 | The actual snapshot is saved in `gatherFileNodes.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## basic flat folder 8 | 9 | > Snapshot 1 10 | 11 | [ 12 | { 13 | data: {}, 14 | isDirectory: Function isDirectory {}, 15 | name: 'random file.md', 16 | path: '/random file.md', 17 | }, 18 | ] 19 | 20 | ## basic case 21 | 22 | > Snapshot 1 23 | 24 | [ 25 | { 26 | data: {}, 27 | isDirectory: Function isDirectory {}, 28 | name: 'file.md', 29 | path: 'deeply/nested/file.md', 30 | }, 31 | ] 32 | 33 | > Snapshot 2 34 | 35 | [ 36 | { 37 | data: {}, 38 | isDirectory: Function isDirectory {}, 39 | name: 'file.md', 40 | path: 'deeply/nested/file.md', 41 | }, 42 | ] 43 | 44 | ## double level recursion 45 | 46 | > Snapshot 1 47 | 48 | [ 49 | { 50 | data: {}, 51 | isDirectory: Function isDirectory {}, 52 | name: 'file.md', 53 | path: 'deeply/nested/file.md', 54 | }, 55 | ] 56 | 57 | ## double level recursion named 58 | 59 | > Snapshot 1 60 | 61 | [ 62 | { 63 | data: { 64 | a: 'nested', 65 | b: 'file', 66 | }, 67 | isDirectory: Function isDirectory {}, 68 | name: 'file.md', 69 | path: 'deeply/nested/file.md', 70 | }, 71 | ] 72 | 73 | ## single level recursion 74 | 75 | > Snapshot 1 76 | 77 | [ 78 | { 79 | data: {}, 80 | isDirectory: Function isDirectory {}, 81 | name: 'random file.md', 82 | path: '/random file.md', 83 | }, 84 | ] 85 | 86 | ## double level recursion named without parent directory 87 | 88 | > Snapshot 1 89 | 90 | [ 91 | { 92 | data: { 93 | genre: 'Comedy', 94 | title: 'Nine Lives of Tomas Katz, The', 95 | }, 96 | isDirectory: Function isDirectory {}, 97 | name: 'Nine Lives of Tomas Katz, The.md', 98 | path: 'Comedy/Nine Lives of Tomas Katz, The.md', 99 | }, 100 | { 101 | data: { 102 | genre: 'Comedy', 103 | title: 'Road to Wellville, The', 104 | }, 105 | isDirectory: Function isDirectory {}, 106 | name: 'Road to Wellville, The.md', 107 | path: 'Comedy/Road to Wellville, The.md', 108 | }, 109 | { 110 | data: { 111 | genre: 'Drama', 112 | title: 'Life for Sale (Life for Sale (Kotirauha)', 113 | }, 114 | isDirectory: Function isDirectory {}, 115 | name: 'Life for Sale (Life for Sale (Kotirauha).md', 116 | path: 'Drama/Life for Sale (Life for Sale (Kotirauha).md', 117 | }, 118 | { 119 | data: { 120 | genre: 'Documentary', 121 | title: 'TerrorStorm: A History of Government-Sponsored Terrorism', 122 | }, 123 | isDirectory: Function isDirectory {}, 124 | name: 'TerrorStorm: A History of Government-Sponsored Terrorism.md', 125 | path: 'Documentary/TerrorStorm: A History of Government-Sponsored Terrorism.md', 126 | }, 127 | ] 128 | 129 | ## single level named 130 | 131 | > Snapshot 1 132 | 133 | [ 134 | { 135 | data: { 136 | title: 'random file', 137 | }, 138 | isDirectory: Function isDirectory {}, 139 | name: 'random file.md', 140 | path: '/random file.md', 141 | }, 142 | ] 143 | 144 | ## double level first named 145 | 146 | > Snapshot 1 147 | 148 | [ 149 | { 150 | data: { 151 | genre: 'Comedy', 152 | }, 153 | isDirectory: Function isDirectory {}, 154 | name: 'Nine Lives of Tomas Katz, The.md', 155 | path: 'Comedy/Nine Lives of Tomas Katz, The.md', 156 | }, 157 | { 158 | data: { 159 | genre: 'Comedy', 160 | }, 161 | isDirectory: Function isDirectory {}, 162 | name: 'Road to Wellville, The.md', 163 | path: 'Comedy/Road to Wellville, The.md', 164 | }, 165 | { 166 | data: { 167 | genre: 'Drama', 168 | }, 169 | isDirectory: Function isDirectory {}, 170 | name: 'Life for Sale (Life for Sale (Kotirauha).md', 171 | path: 'Drama/Life for Sale (Life for Sale (Kotirauha).md', 172 | }, 173 | { 174 | data: { 175 | genre: 'Documentary', 176 | }, 177 | isDirectory: Function isDirectory {}, 178 | name: 'TerrorStorm: A History of Government-Sponsored Terrorism.md', 179 | path: 'Documentary/TerrorStorm: A History of Government-Sponsored Terrorism.md', 180 | }, 181 | ] 182 | 183 | ## double level second named 184 | 185 | > Snapshot 1 186 | 187 | [ 188 | { 189 | data: { 190 | title: 'Nine Lives of Tomas Katz, The', 191 | }, 192 | isDirectory: Function isDirectory {}, 193 | name: 'Nine Lives of Tomas Katz, The.md', 194 | path: 'Comedy/Nine Lives of Tomas Katz, The.md', 195 | }, 196 | { 197 | data: { 198 | title: 'Road to Wellville, The', 199 | }, 200 | isDirectory: Function isDirectory {}, 201 | name: 'Road to Wellville, The.md', 202 | path: 'Comedy/Road to Wellville, The.md', 203 | }, 204 | { 205 | data: { 206 | title: 'Life for Sale (Life for Sale (Kotirauha)', 207 | }, 208 | isDirectory: Function isDirectory {}, 209 | name: 'Life for Sale (Life for Sale (Kotirauha).md', 210 | path: 'Drama/Life for Sale (Life for Sale (Kotirauha).md', 211 | }, 212 | { 213 | data: { 214 | title: 'TerrorStorm: A History of Government-Sponsored Terrorism', 215 | }, 216 | isDirectory: Function isDirectory {}, 217 | name: 'TerrorStorm: A History of Government-Sponsored Terrorism.md', 218 | path: 'Documentary/TerrorStorm: A History of Government-Sponsored Terrorism.md', 219 | }, 220 | ] 221 | 222 | ## triple level 223 | 224 | > Snapshot 1 225 | 226 | [ 227 | { 228 | data: { 229 | name: 'nested', 230 | random: 'deeply', 231 | title: 'file', 232 | }, 233 | isDirectory: Function isDirectory {}, 234 | name: 'file.md', 235 | path: 'deeply/nested/file.md', 236 | }, 237 | ] 238 | -------------------------------------------------------------------------------- /packages/source-filesystem/src/utils/tests/snapshots/gatherFileNodes.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/packages/source-filesystem/src/utils/tests/snapshots/gatherFileNodes.test.ts.snap -------------------------------------------------------------------------------- /packages/source-filesystem/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: false, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/*'], 8 | format: ['esm'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | treeshake: true, 13 | }; 14 | -------------------------------------------------------------------------------- /packages/transformer-markdown/README.md: -------------------------------------------------------------------------------- 1 | # @flatbread/transformer-markdown ⚡ 2 | 3 | > Transform [Markdown](https://en.wikipedia.org/wiki/markdown) files into content that can be fetched with GraphQL. If you're using a CMS like NetlifyCMS, you'll want to pair this with the [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/source-filesystem/README.md) plugin. 4 | 5 | ## 💾 Install 6 | 7 | Use `pnpm`, `npm`, or `yarn`: 8 | 9 | ```bash 10 | pnpm i @flatbread/transformer-markdown 11 | ``` 12 | 13 | ## 👩‍🍳 Usage 14 | 15 | Pair this with a compatible source plugin in your `flatbread.config.js` file: 16 | 17 | ```js 18 | // flatbread.config.js 19 | import defineConfig from '@flatbread/config'; 20 | import transformer from '@flatbread/transformer-markdown'; 21 | import filesystem from '@flatbread/source-filesystem'; 22 | 23 | const transformerConfig = { 24 | markdown: { 25 | gfm: true, 26 | externalLinks: true, 27 | }, 28 | }; 29 | 30 | export default defineConfig({ 31 | source: filesystem({ extensions: ['.md', '.mdx', '.markdown'] }), 32 | transformer: transformer(transformerConfig), 33 | content: [ 34 | { 35 | path: 'content/posts', 36 | collection: 'Post', 37 | refs: { 38 | authors: 'Author', 39 | }, 40 | }, 41 | { 42 | path: 'content/authors', 43 | collection: 'Author', 44 | refs: { 45 | friend: 'Author', 46 | }, 47 | }, 48 | ], 49 | }); 50 | ``` 51 | 52 | Refer to your source plugin's documentation for the relevant `content` Flatbread config option. 53 | 54 | ## 🧰 Options 55 | 56 | Please excuse what I'm about to do as I `CTRL` + `C`, `CTRL` + `V` my types file and hand it off to you as the official API docs for this plugin. If anyone wants to pretty this up, please bust open a PR 💜 57 | 58 | ```ts 59 | /** 60 | * Markdown transformer configuration. 61 | */ 62 | export interface MarkdownTransformerConfig { 63 | /** 64 | * User-configurable options for the [gray-matter](https://www.npmjs.com/package/gray-matter) frontmatter parser. 65 | */ 66 | grayMatter?: GrayMatterConfig; 67 | /** 68 | * User-configurable options for the [unified](https://github.com/unifiedjs/unified) processor. 69 | */ 70 | markdown?: MarkdownConfig; 71 | } 72 | 73 | /** 74 | * An engine may either be an object with parse and 75 | * (optionally) stringify methods, or a function that will 76 | * be used for parsing only. 77 | */ 78 | export type LanguageEngine = 79 | | { 80 | parse: (input: string) => object; 81 | stringify?: (data: object) => string; 82 | } 83 | | ((input: string) => object); 84 | 85 | /** 86 | * User-configurable options for the [gray-matter](https://www.npmjs.com/package/gray-matter) frontmatter parser. 87 | */ 88 | export interface GrayMatterConfig { 89 | /** 90 | * Extract an excerpt that directly follows front-matter, 91 | * or is the first thing in the string if no front-matter 92 | * exists. 93 | * 94 | * If set to excerpt: true, it will look for the frontmatter 95 | * delimiter, `---` by default and grab everything leading up 96 | * to it. 97 | **/ 98 | excerpt?: boolean | ((input: string, options: GrayMatterConfig) => string); 99 | 100 | /** Define a custom separator to use for excerpts. 101 | **/ 102 | excerpt_separator?: string; 103 | /** 104 | * Define custom engines for parsing and/or stringifying 105 | * front-matter. 106 | * 107 | * JSON, YAML and JavaScript are already 108 | * handled by default. 109 | **/ 110 | engines?: Record; 111 | /** 112 | * Define the engine to use for parsing front-matter. 113 | * Defaults to `yaml`. 114 | */ 115 | language?: string; 116 | /** 117 | * Open and close delimiters can be passed in as an array 118 | * of strings. 119 | * 120 | * Defaults to `---`. 121 | */ 122 | delimiters?: string; 123 | } 124 | 125 | /** 126 | * User plugins can be added to the [unified](https://github.com/unifiedjs/unified) processor. 127 | */ 128 | export interface MarkdownConfig { 129 | /** 130 | * Files with these extensions will be parsed. 131 | * 132 | * Defaults to `['.md', '.mdx', '.markdown']`. 133 | */ 134 | extensions?: string[]; 135 | /** 136 | * Github-flavored markdown. 137 | */ 138 | gfm?: boolean; 139 | /** 140 | * Remove empty (or white-space only) paragraphs. 141 | */ 142 | squeezeParagraphs?: boolean; 143 | /** 144 | * Add target and rel attributes to external links. 145 | */ 146 | externalLinks?: boolean; 147 | /** 148 | * Target attribute value for external links. 149 | * Default: `_blank` 150 | */ 151 | externalLinksTarget?: string; 152 | /** 153 | * Rel attribute value for external links. 154 | * Default: ['nofollow', 'noopener', 'noreferrer'] 155 | */ 156 | externalLinksRel?: string[] | string; 157 | /** 158 | * Plugins to add to the [remark](https://github.com/remarkjs/remark) processor. 159 | */ 160 | remarkPlugins?: PluggableList; 161 | /** 162 | * Plugins to add to the [rehype](https://github.com/rehypejs/rehype) processor. 163 | */ 164 | rehypePlugins?: PluggableList; 165 | } 166 | ``` 167 | -------------------------------------------------------------------------------- /packages/transformer-markdown/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/transformer-markdown", 3 | "version": "1.0.0-alpha.7", 4 | "description": "Convert .md files to in-memory JSON model", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 9 | "directory": "packages/transformer-markdown" 10 | }, 11 | "homepage": "https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-markdown#readme", 12 | "author": "Tony Ketcham ", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 16 | }, 17 | "exports": { 18 | ".": "./dist/index.js" 19 | }, 20 | "main": "dist/index.js", 21 | "module": "dist/index.js", 22 | "types": "dist/index.d.ts", 23 | "files": [ 24 | "dist", 25 | "*.d.ts" 26 | ], 27 | "scripts": { 28 | "build": "tsup", 29 | "dev": "tsup --watch src" 30 | }, 31 | "engines": { 32 | "node": "^14.13.1 || >=16.0.0" 33 | }, 34 | "dependencies": { 35 | "@sindresorhus/slugify": "^2.1.0", 36 | "graphql": "16.5.0", 37 | "gray-matter": "^4.0.3", 38 | "lodash-es": "^4.17.21", 39 | "rehype-raw": "^6.1.1", 40 | "rehype-sanitize": "^5.0.1", 41 | "rehype-stringify": "^9.0.3", 42 | "remark": "^14.0.2", 43 | "remark-autolink-headings": "^7.0.1", 44 | "remark-external-links": "^9.0.1", 45 | "remark-fix-guillemets": "^1.1.1", 46 | "remark-footnotes": "^4.0.1", 47 | "remark-frontmatter": "^4.0.1", 48 | "remark-gfm": "^3.0.1", 49 | "remark-html": "^15.0.1", 50 | "remark-parse": "^10.0.1", 51 | "remark-parse-yaml": "^0.0.3", 52 | "remark-rehype": "^10.1.0", 53 | "remark-slug": "^7.0.1", 54 | "remark-squeeze-paragraphs": "^5.0.1", 55 | "remark-stringify": "^10.0.2", 56 | "sanitize-html": "^2.7.0", 57 | "to-vfile": "^7.2.3", 58 | "unified": "^10.1.2" 59 | }, 60 | "devDependencies": { 61 | "@flatbread/core": "workspace:*", 62 | "@types/node": "16.11.47", 63 | "@types/sanitize-html": "2.6.2", 64 | "tsup": "6.2.1", 65 | "typescript": "4.7.4", 66 | "vfile": "5.3.4" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/graphql/schema-helpers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createExcerpt, 3 | estimateTimeToRead, 4 | transformContentToHTML, 5 | } from '../processors'; 6 | import sanitizeHtml from 'sanitize-html'; 7 | import { MarkdownTransformerConfig } from '../types'; 8 | 9 | /** 10 | * A GraphQL field for the time to read the content, if it exists. 11 | */ 12 | export const timeToRead = (config: MarkdownTransformerConfig) => () => ({ 13 | type: () => 'Int', 14 | description: 15 | 'How long (in minutes) it would take an average reader to read the main content.', 16 | args: { 17 | speed: { 18 | type: () => 'Int', 19 | description: 'The reading speed in words per minute', 20 | defaultValue: 230, 21 | }, 22 | }, 23 | resolve: async (parentNode: any, args: { speed: number }) => { 24 | if (!parentNode.html) { 25 | parentNode.html = await transformContentToHTML( 26 | parentNode.raw, 27 | config.markdown ?? {} 28 | ); 29 | } 30 | 31 | const plaintext = parentNode 32 | ? sanitizeHtml(parentNode.html, { 33 | allowedAttributes: {}, 34 | allowedTags: [], 35 | }).replace(/\r?\n|\r/g, ' ') 36 | : ''; 37 | 38 | return estimateTimeToRead(plaintext, args.speed); 39 | }, 40 | }); 41 | 42 | /** 43 | * A GraphQL field for an excerpt of the content, if it exists. 44 | */ 45 | export const excerpt = (config: MarkdownTransformerConfig) => () => ({ 46 | type: 'String', 47 | description: 'A plaintext excerpt taken from the main content', 48 | args: { 49 | length: { 50 | type: () => 'Int', 51 | description: 'The length of the excerpt in words', 52 | defaultValue: 200, 53 | }, 54 | }, 55 | resolve: async (parentNode: any, args: { length: number }) => { 56 | if (!parentNode.html) { 57 | parentNode.html = await transformContentToHTML( 58 | parentNode.raw, 59 | config.markdown ?? {} 60 | ); 61 | } 62 | 63 | const plaintext = parentNode 64 | ? sanitizeHtml(parentNode.html, { 65 | allowedAttributes: {}, 66 | allowedTags: [], 67 | }).replace(/\r?\n|\r/g, ' ') 68 | : ''; 69 | return createExcerpt(plaintext, args.length); 70 | }, 71 | }); 72 | 73 | /** 74 | * A GraphQL field for the content as HTML, if it exists. 75 | */ 76 | export const html = (config: MarkdownTransformerConfig) => () => ({ 77 | type: () => 'String', 78 | description: 'The content as HTML', 79 | resolve: async (parentNode: any) => { 80 | if (!parentNode.html) { 81 | parentNode.html = await transformContentToHTML( 82 | parentNode.raw, 83 | config.markdown ?? {} 84 | ); 85 | } 86 | return parentNode.html; 87 | }, 88 | }); 89 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/index.ts: -------------------------------------------------------------------------------- 1 | import matter from 'gray-matter'; 2 | import slugify from '@sindresorhus/slugify'; 3 | import { html, excerpt, timeToRead } from './graphql/schema-helpers'; 4 | 5 | import type { MarkdownTransformerConfig } from './types'; 6 | import type { EntryNode, TransformerPlugin } from '@flatbread/core'; 7 | import type { VFile } from 'vfile'; 8 | 9 | export * from './types'; 10 | 11 | /** 12 | * Transforms a markdown file (content node) to JSON containing any frontmatter data or content. 13 | * 14 | * @param {VFile} input - A VFile object representing a content node. 15 | * @param {MarkdownTransformerConfig} config - A configuration object. 16 | */ 17 | export const parse = ( 18 | input: VFile, 19 | config: MarkdownTransformerConfig 20 | ): EntryNode => { 21 | const { data, content } = matter(String(input), config.grayMatter); 22 | return { 23 | _filename: input.basename, 24 | _path: input.path, 25 | _slug: slugify(input.stem ?? ''), 26 | ...input.data, 27 | ...data, 28 | _content: { 29 | raw: content, 30 | }, 31 | }; 32 | }; 33 | 34 | /** 35 | * Converts markdown files to meaningful data. 36 | * 37 | * @param config Markdown transformer configuration. 38 | * @returns Markdown parser, preknown GraphQL schema fragments, and an EntryNode inspector function. 39 | */ 40 | export const transformer: TransformerPlugin = ( 41 | config: MarkdownTransformerConfig = {} 42 | ) => { 43 | const extensions = (config.extensions || ['.md']).map((ext: string) => 44 | ext.startsWith('.') ? ext : `.${ext}` 45 | ); 46 | return { 47 | parse: (input: VFile): EntryNode => parse(input, config), 48 | preknownSchemaFragments: () => ({ 49 | _content: { 50 | html: html(config), 51 | excerpt: excerpt(config), 52 | timeToRead: timeToRead(config), 53 | }, 54 | }), 55 | inspect: (input: EntryNode) => String(input), 56 | extensions, 57 | }; 58 | }; 59 | 60 | export default transformer; 61 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/processors/excerpt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generate an excerpt from plaintext. 3 | * @param text The text to generate an excerpt from. 4 | * @param length The length of the excerpt. 5 | */ 6 | export default function createExcerpt(text: string, length: number): string { 7 | if (text.length <= length) { 8 | return text; 9 | } 10 | return text.slice(0, length) + '…'; 11 | } 12 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/processors/index.ts: -------------------------------------------------------------------------------- 1 | import createExcerpt from './excerpt'; 2 | import estimateTimeToRead from './timeToRead'; 3 | import transformContentToHTML from './toHTML'; 4 | 5 | export { createExcerpt, estimateTimeToRead, transformContentToHTML }; 6 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/processors/markdown.ts: -------------------------------------------------------------------------------- 1 | import { unified } from 'unified'; 2 | import remarkFrontmatter from 'remark-frontmatter'; 3 | import remarkParse from 'remark-parse'; 4 | import rehypeRaw from 'rehype-raw'; 5 | import remarkRehype from 'remark-rehype'; 6 | import rehypeStringify from 'rehype-stringify'; 7 | import rehypeSanitize from 'rehype-sanitize'; 8 | import external from 'remark-external-links'; 9 | import gfm from 'remark-gfm'; 10 | 11 | import type { 12 | MarkdownConfig, 13 | Processor, 14 | PluggableList, 15 | Plugin, 16 | } from '../types'; 17 | import type { Options as ExternalLinksOptions } from 'remark-external-links'; 18 | 19 | /** 20 | * Add plugins with optional config to the processor via mutation. 21 | * @param plugins Tuples of plugin and config 22 | * @param processor UnifiedJS processor 23 | * @returns the processor 24 | */ 25 | const applyPlugins = ( 26 | plugins: PluggableList, 27 | processor: Processor 28 | ): Processor => { 29 | plugins.forEach((plugin) => { 30 | if (Array.isArray(plugin)) { 31 | if (plugin[1] && plugin[1]) processor.use(plugin[0] as Plugin, plugin[1]); 32 | else processor.use(plugin[0]); 33 | } else { 34 | processor.use(plugin as Plugin); 35 | } 36 | }); 37 | 38 | return processor; 39 | }; 40 | 41 | /** 42 | * Factory function to create a processor for markdown files. 43 | * @param options Markdown config 44 | * @returns [Unified](https://github.com/unifiedjs/unified) markdown processor 45 | */ 46 | export function createMarkdownProcessor( 47 | options: MarkdownConfig = {} 48 | ): Processor { 49 | const toMDAST = unified().use(remarkParse).use(remarkFrontmatter); 50 | 51 | if (options.remarkPlugins) { 52 | applyPlugins(options.remarkPlugins, toMDAST); 53 | } 54 | if (options.gfm) { 55 | toMDAST.use(gfm); 56 | } 57 | if (options.externalLinks) { 58 | const externalLinksConfig = { 59 | target: options?.externalLinksTarget ?? '_blank', 60 | rel: options?.externalLinksRel ?? ['nofollow', 'noopener', 'noreferrer'], 61 | }; 62 | toMDAST.use(external, externalLinksConfig as ExternalLinksOptions); 63 | } 64 | 65 | const toHAST = toMDAST 66 | .use(remarkRehype, { allowDangerousHtml: true }) 67 | .use(rehypeRaw) 68 | .use(rehypeSanitize); 69 | 70 | if (options.rehypePlugins) { 71 | applyPlugins(options.rehypePlugins, toHAST); 72 | } 73 | 74 | const processor = toHAST.use(rehypeStringify); 75 | return processor; 76 | } 77 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/processors/timeToRead.ts: -------------------------------------------------------------------------------- 1 | // Word count in respect of CJK characters 2 | // Borrowed from: https://github.com/yuehu/word-count 3 | 4 | const pattern = 5 | /[a-zA-Z0-9_\u0392-\u03c9\u00c0-\u00ff\u0600-\u06ff\u0400-\u04ff]+|[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff\u3040-\u309f\uac00-\ud7af]+/g; 6 | 7 | export default function estimateTimeToRead(text: string, speed: number) { 8 | const m = text.match(pattern); 9 | let count = 0; 10 | 11 | // Empty string 12 | if (!m) { 13 | return 0; 14 | } 15 | 16 | for (const c of m) { 17 | if (c.charCodeAt(0) >= 0x4e00) { 18 | count += c.length; 19 | } else { 20 | count += 1; 21 | } 22 | } 23 | 24 | return Math.round(count / speed) || 1; 25 | } 26 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/processors/toHTML.ts: -------------------------------------------------------------------------------- 1 | import { createMarkdownProcessor } from './markdown'; 2 | 3 | import type { MarkdownConfig } from '..'; 4 | 5 | export default async function transformContentToHTML( 6 | content: string, 7 | markdownConfig: MarkdownConfig 8 | ): Promise { 9 | const markdownProcessor = createMarkdownProcessor(markdownConfig); 10 | const html = content ? String(await markdownProcessor.process(content)) : ''; 11 | return html; 12 | } 13 | -------------------------------------------------------------------------------- /packages/transformer-markdown/src/types.ts: -------------------------------------------------------------------------------- 1 | import type { PluggableList, PluginTuple, Plugin, Processor } from 'unified'; 2 | export { PluggableList, PluginTuple, Plugin, Processor }; 3 | 4 | /** 5 | * Markdown transformer configuration. 6 | */ 7 | export interface MarkdownTransformerConfig { 8 | /** 9 | * User-configurable options for the [gray-matter](https://www.npmjs.com/package/gray-matter) frontmatter parser. 10 | */ 11 | grayMatter?: GrayMatterConfig; 12 | /** 13 | * User-configurable options for the [unified](https://github.com/unifiedjs/unified) processor. 14 | */ 15 | markdown?: MarkdownConfig; 16 | /** 17 | * Specify file extensions to transform. Defaults to `['.md']` 18 | */ 19 | extensions?: string[]; 20 | } 21 | 22 | /** 23 | * An engine may either be an object with parse and 24 | * (optionally) stringify methods, or a function that will 25 | * be used for parsing only. 26 | */ 27 | export type LanguageEngine = 28 | | { 29 | parse: (input: string) => object; 30 | stringify?: (data: object) => string; 31 | } 32 | | ((input: string) => object); 33 | 34 | /** 35 | * User-configurable options for the [gray-matter](https://www.npmjs.com/package/gray-matter) frontmatter parser. 36 | */ 37 | export interface GrayMatterConfig { 38 | /** 39 | * Extract an excerpt that directly follows front-matter, 40 | * or is the first thing in the string if no front-matter 41 | * exists. 42 | * 43 | * If set to excerpt: true, it will look for the frontmatter 44 | * delimiter, `---` by default and grab everything leading up 45 | * to it. 46 | **/ 47 | excerpt?: boolean | ((input: string, options: GrayMatterConfig) => string); 48 | 49 | /** Define a custom separator to use for excerpts. 50 | **/ 51 | excerpt_separator?: string; 52 | /** 53 | * Define custom engines for parsing and/or stringifying 54 | * front-matter. 55 | * 56 | * JSON, YAML and JavaScript are already 57 | * handled by default. 58 | **/ 59 | engines?: Record; 60 | /** 61 | * Define the engine to use for parsing front-matter. 62 | * Defaults to `yaml`. 63 | */ 64 | language?: string; 65 | /** 66 | * Open and close delimiters can be passed in as an array 67 | * of strings. 68 | * 69 | * Defaults to `---`. 70 | */ 71 | delimiters?: string; 72 | } 73 | 74 | /** 75 | * User plugins can be added to the [unified](https://github.com/unifiedjs/unified) processor. 76 | */ 77 | export interface MarkdownConfig { 78 | /** 79 | * Files with these extensions will be parsed. 80 | * 81 | * Defaults to `['.md', '.mdx', '.markdown']`. 82 | */ 83 | extensions?: string[]; 84 | /** 85 | * Github-flavored markdown. 86 | */ 87 | gfm?: boolean; 88 | /** 89 | * Remove empty (or white-space only) paragraphs. 90 | */ 91 | squeezeParagraphs?: boolean; 92 | /** 93 | * Add target and rel attributes to external links. 94 | */ 95 | externalLinks?: boolean; 96 | /** 97 | * Target attribute value for external links. 98 | * Default: `_blank` 99 | */ 100 | externalLinksTarget?: string; 101 | /** 102 | * Rel attribute value for external links. 103 | * Default: ['nofollow', 'noopener', 'noreferrer'] 104 | */ 105 | externalLinksRel?: string[] | string; 106 | /** 107 | * Plugins to add to the [remark](https://github.com/remarkjs/remark) processor. 108 | */ 109 | remarkPlugins?: PluggableList; 110 | /** 111 | * Plugins to add to the [rehype](https://github.com/rehypejs/rehype) processor. 112 | */ 113 | rehypePlugins?: PluggableList; 114 | } 115 | -------------------------------------------------------------------------------- /packages/transformer-markdown/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: false, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/*'], 8 | format: ['esm'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/transformer-yaml/README.md: -------------------------------------------------------------------------------- 1 | # @flatbread/transformer-yaml 🐪 2 | 3 | > Transform [YAML](https://en.wikipedia.org/wiki/YAML) files into content that can be fetched with GraphQL. 4 | 5 | ## 💾 Install 6 | 7 | Use `pnpm`, `npm`, or `yarn`: 8 | 9 | ```bash 10 | pnpm i @flatbread/transformer-yaml 11 | ``` 12 | 13 | ## 👩‍🍳 Usage 14 | 15 | Pair this with a compatible source plugin in your `flatbread.config.js` file: 16 | 17 | ```js 18 | // flatbread.config.js 19 | import defineConfig from '@flatbread/config'; 20 | import transformer from '@flatbread/transformer-markdown'; 21 | import filesystem from '@flatbread/source-filesystem'; 22 | 23 | export default defineConfig({ 24 | source: filesystem(), 25 | transformer: transformer(), 26 | content: [ 27 | { 28 | path: 'content/posts', 29 | collection: 'Post', 30 | refs: { 31 | authors: 'Author', 32 | }, 33 | }, 34 | { 35 | path: 'content/authors', 36 | collection: 'Author', 37 | refs: { 38 | friend: 'Author', 39 | }, 40 | }, 41 | ], 42 | }); 43 | ``` 44 | 45 | ### Options 46 | 47 | This transformer plugin currently does not accept any config options. It supports all valid yaml syntax flavors by default. 48 | 49 | Refer to your source plugin's documentation for the relevant `content` Flatbread config option. 50 | 51 | If you're using a CMS like NetlifyCMS, you'll want to pair this with the [`source-filesystem`](https://github.com/FlatbreadLabs/flatbread/blob/main/packages/source-filesystem/README.md) plugin. 52 | -------------------------------------------------------------------------------- /packages/transformer-yaml/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@flatbread/transformer-yaml", 3 | "version": "1.0.0-alpha.7", 4 | "description": "Convert YAML files to an in-memory JSON model", 5 | "type": "module", 6 | "repository": { 7 | "type": "git", 8 | "url": "git+https://github.com/FlatbreadLabs/flatbread.git", 9 | "directory": "packages/transformer-yaml" 10 | }, 11 | "homepage": "https://github.com/FlatbreadLabs/flatbread/tree/main/packages/transformer-yaml#readme", 12 | "author": "Tony Ketcham ", 13 | "license": "MIT", 14 | "bugs": { 15 | "url": "https://github.com/FlatbreadLabs/flatbread/issues" 16 | }, 17 | "exports": { 18 | ".": "./dist/index.js" 19 | }, 20 | "main": "dist/index.js", 21 | "module": "dist/index.js", 22 | "types": "dist/index.d.ts", 23 | "files": [ 24 | "dist", 25 | "*.d.ts" 26 | ], 27 | "scripts": { 28 | "build": "tsup", 29 | "dev": "tsup --watch src" 30 | }, 31 | "engines": { 32 | "node": "^14.13.1 || >=16.0.0" 33 | }, 34 | "dependencies": { 35 | "@sindresorhus/slugify": "^2.1.0", 36 | "js-yaml": "^4.1.0" 37 | }, 38 | "devDependencies": { 39 | "@flatbread/core": "workspace:*", 40 | "@types/js-yaml": "4.0.5", 41 | "@types/node": "16.11.47", 42 | "tsup": "6.2.1", 43 | "typescript": "4.7.4", 44 | "vfile": "5.3.4" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/transformer-yaml/src/index.ts: -------------------------------------------------------------------------------- 1 | import yaml from 'js-yaml'; 2 | import type { YAMLException } from 'js-yaml'; 3 | import slugify from '@sindresorhus/slugify'; 4 | import type { EntryNode, TransformerPlugin } from '@flatbread/core'; 5 | import type { VFile } from 'vfile'; 6 | 7 | /** 8 | * Transforms a yaml file (content node) to JSON. 9 | * 10 | * @param {VFile} input - A VFile object representing a content node. 11 | */ 12 | export const parse = (input: VFile): EntryNode => { 13 | const doc = yaml.load(String(input), { 14 | filename: input.path, 15 | onWarning: (warning: YAMLException) => 16 | console.log(console.warn(warning.toString())), 17 | }); 18 | 19 | if (typeof doc === 'object') { 20 | return { 21 | _filename: input.basename, 22 | _path: input.path, 23 | _slug: slugify(input.stem ?? ''), 24 | ...input.data, 25 | ...doc, 26 | }; 27 | } 28 | throw new Error( 29 | `Parsing ${ 30 | input.path 31 | } yielded a '${typeof doc}' when an 'object' was expected.` 32 | ); 33 | }; 34 | 35 | /** 36 | * Converts markdown files to meaningful data. 37 | * 38 | * @returns Markdown parser, preknown GraphQL schema fragments, and an EntryNode inspector function. 39 | */ 40 | export const transformer: TransformerPlugin = () => { 41 | return { 42 | parse: (input: VFile): EntryNode => parse(input), 43 | inspect: (input: EntryNode) => String(input), 44 | extensions: ['.yaml', '.yml'], 45 | }; 46 | }; 47 | 48 | export default transformer; 49 | -------------------------------------------------------------------------------- /packages/transformer-yaml/src/tests/index.test.ts: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import { EntryNode } from 'flatbread'; 3 | import { VFile } from 'vfile'; 4 | 5 | import Transformer from '../index.js'; 6 | 7 | const testFile = new VFile(` 8 | id: 2a3e 9 | name: Tony 10 | enjoys: 11 | - cats 12 | - tea 13 | - making this 14 | friend: 40s3 15 | date_joined: 2021-02-25T16:41:59.558Z 16 | skills: 17 | sitting: 204 18 | breathing: 7.07 19 | liquid_consumption: 100 20 | existence: simulation 21 | sports: -2 22 | cat_pat: 1500 23 | `); 24 | 25 | const transformer = Transformer(); 26 | 27 | test('it can parse a basic yaml file', async (t) => { 28 | const parse = transformer.parse as (input: VFile) => EntryNode; 29 | const node = parse(testFile); 30 | t.snapshot(node); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/transformer-yaml/src/tests/index.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/transformer-yaml/src/index.test.ts` 2 | 3 | The actual snapshot is saved in `index.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## it can parse a basic yaml file 8 | 9 | > Snapshot 1 10 | 11 | { 12 | _filename: undefined, 13 | _path: undefined, 14 | _slug: '', 15 | date_joined: Date 2021-02-25 16:41:59 558ms UTC {}, 16 | enjoys: [ 17 | 'cats', 18 | 'tea', 19 | 'making this', 20 | ], 21 | friend: '40s3', 22 | id: '2a3e', 23 | name: 'Tony', 24 | skills: { 25 | breathing: 7.07, 26 | cat_pat: 1500, 27 | existence: 'simulation', 28 | liquid_consumption: 100, 29 | sitting: 204, 30 | sports: -2, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /packages/transformer-yaml/src/tests/index.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/packages/transformer-yaml/src/tests/index.test.ts.snap -------------------------------------------------------------------------------- /packages/transformer-yaml/src/tests/snapshots/index.test.ts.md: -------------------------------------------------------------------------------- 1 | # Snapshot report for `packages/transformer-yaml/src/tests/index.test.ts` 2 | 3 | The actual snapshot is saved in `index.test.ts.snap`. 4 | 5 | Generated by [AVA](https://avajs.dev). 6 | 7 | ## it can parse a basic yaml file 8 | 9 | > Snapshot 1 10 | 11 | { 12 | _filename: undefined, 13 | _path: undefined, 14 | _slug: '', 15 | date_joined: Date 2021-02-25 16:41:59 558ms UTC {}, 16 | enjoys: [ 17 | 'cats', 18 | 'tea', 19 | 'making this', 20 | ], 21 | friend: '40s3', 22 | id: '2a3e', 23 | name: 'Tony', 24 | skills: { 25 | breathing: 7.07, 26 | cat_pat: 1500, 27 | existence: 'simulation', 28 | liquid_consumption: 100, 29 | sitting: 204, 30 | sports: -2, 31 | }, 32 | } 33 | -------------------------------------------------------------------------------- /packages/transformer-yaml/src/tests/snapshots/index.test.ts.snap: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FlatbreadLabs/flatbread/b1656d95eb5efbbc0d1582fcf20218be65d990ba/packages/transformer-yaml/src/tests/snapshots/index.test.ts.snap -------------------------------------------------------------------------------- /packages/transformer-yaml/tsup.config.ts: -------------------------------------------------------------------------------- 1 | // tsup.config.ts 2 | import type { Options } from 'tsup'; 3 | export const tsup: Options = { 4 | splitting: false, 5 | sourcemap: true, 6 | clean: true, 7 | entryPoints: ['src/*'], 8 | format: ['esm'], 9 | target: 'esnext', 10 | dts: true, 11 | shims: true, 12 | treeshake: true, 13 | }; 14 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/* 3 | - '!**/test/**' 4 | - '!**/__tests__/**' 5 | - examples/* 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "labels": ["dependencies"], 3 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 4 | "extends": ["config:base"] 5 | } 6 | -------------------------------------------------------------------------------- /scripts/bumpVersions.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import inquirer from 'inquirer'; 3 | import colors from 'kleur'; 4 | import path from 'node:path'; 5 | import { 6 | getMonorepoPublicPackages, 7 | PathedFlatbreadPackage, 8 | } from './utils/packageManifest'; 9 | 10 | // Get the list of public packages in the monorepo 11 | const packages = await getMonorepoPublicPackages(); 12 | 13 | const { selectedPackages }: Record = 14 | await inquirer.prompt([ 15 | { 16 | type: 'checkbox', 17 | name: 'selectedPackages', 18 | message: 'Which packages do you want to bump?', 19 | choices: packages.map((pkg) => ({ 20 | name: `${pkg.name}`, 21 | value: pkg, 22 | })), 23 | }, 24 | ]); 25 | 26 | selectedPackages.forEach((selectedPackage) => { 27 | console.log(colors.bold(colors.yellow(`Bumping ${selectedPackage.name}`))); 28 | execSync('pnpm bumpp --no-commit --no-push --no-tag', { 29 | stdio: 'inherit', 30 | cwd: path.resolve(path.join('packages', selectedPackage.dirName)), 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /scripts/publish.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import path from 'path'; 3 | import colors from 'kleur'; 4 | import { getMonorepoPublicPackages } from './utils/packageManifest'; 5 | // import { version } from '../package.json'; 6 | 7 | execSync('pnpm run build', { stdio: 'inherit' }); 8 | 9 | // Start building the npm registry publish command 10 | let command = 'pnpm publish --access public'; 11 | 12 | // Get the list of public packages in the monorepo 13 | const packages = await getMonorepoPublicPackages(); 14 | 15 | // 16 | // For each package, release it. 17 | // This will publish the package to the npm registry if the version has changed; otherwise it will error and move on. 18 | // 19 | for (const { dirName, name, version } of packages) { 20 | try { 21 | // Disabled for now as we are just publishing alpha/beta as the latest version before v1.0.0 22 | // if ((version as string | undefined)?.includes('alpha')) { 23 | // command += ' --tag alpha'; 24 | // } else if ((version as string | undefined)?.includes('beta')) { 25 | // command += ' --tag beta'; 26 | // } 27 | 28 | execSync(command, { 29 | stdio: 'inherit', 30 | cwd: path.resolve(path.join('packages', dirName)), 31 | }); 32 | } catch (_) { 33 | console.log(colors.red(`${name} ${version} failed to publish`)); 34 | } 35 | console.log(colors.bold().green(`Published ${name} v${version}`)); 36 | } 37 | -------------------------------------------------------------------------------- /scripts/utils/packageManifest.ts: -------------------------------------------------------------------------------- 1 | import { readdir } from 'fs/promises'; 2 | import colors from 'kleur'; 3 | import { join } from 'path'; 4 | import flatbreadPackage from '../../packages/flatbread/package.json'; 5 | 6 | export type FlatbreadPackage = typeof flatbreadPackage; 7 | 8 | /** 9 | * The package.json of the flatbread package with a path to the package. 10 | */ 11 | export type PathedFlatbreadPackage = FlatbreadPackage & { 12 | dirName: string; 13 | }; 14 | 15 | /** 16 | * Returns the packages manifest for all public packages in the monorepo. 17 | * 18 | * @param dirs list of directories to search 19 | * @returns 20 | */ 21 | export async function getPackagesManifest( 22 | dirs: string[] 23 | ): Promise { 24 | const pkgManifest = 'package.json'; 25 | 26 | const pkgs = await Promise.all( 27 | dirs.map(async (dir) => ({ 28 | ...(await import(join(process.cwd() + '/packages', dir, pkgManifest))), 29 | dirName: dir, 30 | })) 31 | ); 32 | 33 | return pkgs.filter( 34 | (pkg) => !(pkg.default.private && pkg.default.private === true) 35 | ); 36 | } 37 | 38 | export async function getMonorepoPublicPackages(): Promise< 39 | PathedFlatbreadPackage[] 40 | > { 41 | const dirents = await readdir('packages', { withFileTypes: true }); 42 | const dirs = dirents 43 | .filter((dirent) => dirent.isDirectory()) 44 | .map((dirent) => dirent.name); 45 | 46 | console.group(colors.bold(colors.green('Found public packages...'))); 47 | console.log(dirs); 48 | console.groupEnd(); 49 | 50 | return getPackagesManifest(dirs); 51 | } 52 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "ES2020", 5 | "lib": ["esnext", "dom"], 6 | "moduleResolution": "node", 7 | "esModuleInterop": true, 8 | "strict": true, 9 | "strictNullChecks": true, 10 | "resolveJsonModule": true, 11 | "skipDefaultLibCheck": true, 12 | "paths": { 13 | "flatbread": ["./packages/flatbread/src/index.ts"], 14 | "@flatbread/core": ["./packages/core/src/index.ts"], 15 | "@flatbread/config": ["./packages/config/src/index.ts"], 16 | "@flatbread/source-filesystem": [ 17 | "./packages/source-filesystem/src/index.ts" 18 | ], 19 | "@flatbread/transformer-markdown": [ 20 | "./packages/transformer-markdown/src/index.ts" 21 | ], 22 | "@flatbread/transformer-yaml": [ 23 | "./packages/transformer-yaml/src/index.ts" 24 | ] 25 | } 26 | }, 27 | "exclude": ["**/dist/**", "**/node_modules/**"] 28 | } 29 | --------------------------------------------------------------------------------