Skip to content

Commit

Permalink
feat(file): link to file for download
Browse files Browse the repository at this point in the history
resolves Downloadable files #148
  • Loading branch information
jbmoelker committed Nov 6, 2024
1 parent 74d7ba4 commit d4bef8b
Show file tree
Hide file tree
Showing 15 changed files with 307 additions and 5 deletions.
1 change: 0 additions & 1 deletion astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export default defineConfig({
output: isPreview ? 'server' : 'static',
server: { port: localhostPort },
site: siteUrl,
trailingSlash: 'always',
vite: {
plugins: [
graphql() as PluginOption,
Expand Down
169 changes: 169 additions & 0 deletions config/datocms/migrations/1730927867_fileModel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { Client } from '@datocms/cli/lib/cma-client-node';

export default async function (client: Client) {
console.log('Manage upload filters');

console.log('Install plugin "Computed Fields"');
await client.plugins.create({
id: 'EiyZ3d0SSDCPCNbsKBIwWQ',
package_name: 'datocms-plugin-computed-fields',
});
await client.plugins.update('EiyZ3d0SSDCPCNbsKBIwWQ', {
parameters: { migratedFromLegacyPlugin: true },
});

console.log('Create new models/block models');

console.log('Create model "\uD83D\uDCE6 File" (`file`)');
await client.itemTypes.create(
{
id: 'GjWw8t-hTFaYYWyc53FeIg',
name: '\uD83D\uDCE6 File',
api_key: 'file',
collection_appearance: 'table',
inverse_relationships_enabled: true,
},
{
skip_menu_item_creation: true,
schema_menu_item_id: 'Q7zpH-QAQJ2XBI2CcBio5w',
}
);

console.log('Creating new fields/fieldsets');

console.log(
'Create Single asset field "File" (`file`) in model "\uD83D\uDCE6 File" (`file`)'
);
await client.fields.create('GjWw8t-hTFaYYWyc53FeIg', {
id: 'LolhguizT_mZMl1zzZtQ4Q',
label: 'File',
field_type: 'file',
api_key: 'file',
validators: { required: {} },
appearance: { addons: [], editor: 'file', parameters: {} },
default_value: null,
});

console.log(
'Create Single-line string field "Locale" (`locale`) in model "\uD83D\uDCE6 File" (`file`)'
);
await client.fields.create('GjWw8t-hTFaYYWyc53FeIg', {
id: 'JcOc0SI4RYONRdiNvys1Rg',
label: 'Locale',
field_type: 'string',
api_key: 'locale',
hint: 'Only relevant for files containing text.',
appearance: {
addons: [],
editor: 'string_select',
parameters: {
options: [
{ hint: '', label: 'Deutsch', value: 'de' },
{ hint: '', label: 'English', value: 'en' },
{ hint: '', label: 'Espa\u00F1ol', value: 'es' },
{ hint: '', label: 'Fran\u00E7ais', value: 'fr' },
{ hint: '', label: 'Italiano', value: 'it' },
{ hint: '', label: 'Nederlands', value: 'nl' },
],
},
},
default_value: '',
});

console.log(
'Create Single-line string field "Title" (`title`) in model "\uD83D\uDCE6 File" (`file`)'
);
await client.fields.create('GjWw8t-hTFaYYWyc53FeIg', {
id: 'YIftd04cTlyz0aEvqsfWXA',
label: 'Title',
field_type: 'string',
api_key: 'title',
hint: 'This title is automatically generated based on the filename of the selected file. This ensures you can recognise this record in overviews and when inserting it in a text field.',
validators: { required: {} },
appearance: {
addons: [],
editor: 'EiyZ3d0SSDCPCNbsKBIwWQ',
parameters: {
defaultFunction:
'const upload = await getUpload(file.upload_id)\nreturn `${upload.filename}`',
},
field_extension: 'computedFields',
},
default_value: '',
});

console.log('Update existing fields/fieldsets');

console.log(
'Update Structured text field "Text" (`text`) in block model "\uD83D\uDCDD \uD83D\uDDBC\uFE0F Text Image Block" (`text_image_block`)'
);
await client.fields.update('V4dMfrWsQ027JYEp6q3KhA', {
validators: {
required: {},
structured_text_blocks: {
item_types: [
'ZdBokLsWRgKKjHrKeJzdpw',
'gezG9nO7SfaiWcWnp-HNqw',
'0SxYNS2CR1it_5LHYWuEQg',
],
},
structured_text_links: {
on_publish_with_unpublished_references_strategy: 'fail',
on_reference_unpublish_strategy: 'delete_references',
on_reference_delete_strategy: 'delete_references',
item_types: [
'GjWw8t-hTFaYYWyc53FeIg',
'LjXdkuCdQxCFT4hv8_ayew',
'X_tZn3TxQY28ltSyjZUGHQ',
],
},
},
});

console.log(
'Update Structured text field "Text" (`text`) in block model "\uD83D\uDCDD Text Block" (`text_block`)'
);
await client.fields.update('NtVXfZ6gTL2sKNffNeUf5Q', {
validators: {
required: {},
structured_text_blocks: {
item_types: [
'QYfZyBzIRWKxA1MinIR0aQ',
'ZdBokLsWRgKKjHrKeJzdpw',
'gezG9nO7SfaiWcWnp-HNqw',
'0SxYNS2CR1it_5LHYWuEQg',
],
},
structured_text_links: {
on_publish_with_unpublished_references_strategy: 'fail',
on_reference_unpublish_strategy: 'delete_references',
on_reference_delete_strategy: 'delete_references',
item_types: [
'GjWw8t-hTFaYYWyc53FeIg',
'LjXdkuCdQxCFT4hv8_ayew',
'X_tZn3TxQY28ltSyjZUGHQ',
],
},
},
});

console.log('Finalize models/block models');

console.log('Update model "\uD83D\uDCE6 File" (`file`)');
await client.itemTypes.update('GjWw8t-hTFaYYWyc53FeIg', {
title_field: { id: 'YIftd04cTlyz0aEvqsfWXA', type: 'field' },
image_preview_field: { id: 'LolhguizT_mZMl1zzZtQ4Q', type: 'field' },
});

console.log('Manage menu items');

console.log('Create menu item "\uD83D\uDCE6 Files"');
await client.menuItems.create({
id: 'P1lE98ITSp-2unhby2N59g',
label: '\uD83D\uDCE6 Files',
item_type: { id: 'GjWw8t-hTFaYYWyc53FeIg', type: 'item_type' },
});

console.log('Update menu item "\uD83D\uDCE6 Files"');
await client.menuItems.update('P1lE98ITSp-2unhby2N59g', { position: 5 });
}
2 changes: 1 addition & 1 deletion datocms-environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@
* @see docs/getting-started.md on how to use this file
* @see docs/decision-log/2023-10-24-datocms-env-file.md on why file is preferred over env vars
*/
export const datocmsEnvironment = '160-link-records';
export const datocmsEnvironment = '148-file-downloads';
export const datocmsBuildTriggerId = '30535';
12 changes: 12 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
"jiti": "^1.20.0",
"nanostores": "^0.9.5",
"oembed-providers": "^1.0.20230906",
"pretty-bytes": "^6.1.1",
"promise-all-props": "^3.0.0",
"regexparam": "^3.0.0",
"rosetta": "^1.1.0",
Expand Down
2 changes: 2 additions & 0 deletions src/assets/icons/download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/blocks/TextBlock/TextBlock.fragment.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
#import '@blocks/TableBlock/TableBlock.fragment.graphql'
#import '@blocks/VideoBlock/VideoBlock.fragment.graphql'
#import '@blocks/VideoEmbedBlock/VideoEmbedBlock.fragment.graphql'
#import '@lib/routing/FileRoute.fragment.graphql'
#import '@lib/routing/HomeRoute.fragment.graphql'
#import '@lib/routing/PageRoute.fragment.graphql'

Expand All @@ -24,6 +25,9 @@ fragment TextBlock on TextBlockRecord {
}
links {
__typename
... on FileRecord {
...FileRoute
}
... on HomePageRecord {
...HomeRoute
}
Expand Down
4 changes: 4 additions & 0 deletions src/blocks/TextImageBlock/TextImageBlock.fragment.graphql
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#import '@blocks/ImageBlock/ImageBlock.fragment.graphql'
#import '@blocks/TableBlock/TableBlock.fragment.graphql'
#import '@blocks/VideoEmbedBlock/VideoEmbedBlock.fragment.graphql'
#import '@lib/routing/FileRoute.fragment.graphql'
#import '@lib/routing/HomeRoute.fragment.graphql'
#import '@lib/routing/PageRoute.fragment.graphql'

Expand All @@ -20,6 +21,9 @@ fragment TextImageBlock on TextImageBlockRecord {
}
links {
__typename
... on FileRecord {
...FileRoute
}
... on HomePageRecord {
...HomeRoute
}
Expand Down
43 changes: 43 additions & 0 deletions src/components/LinkToFile/LinkToFile.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
import type { FileRouteFragment } from '@lib/types/datocms';
import prettyBytes from 'pretty-bytes';
import { getLocale } from '@lib/i18n';
import { getFileHref } from '@lib/routing/';
import Icon from '@components/Icon';
type Props = {
record: FileRouteFragment;
};
const { record, ...props } = Astro.props;
const { file, locale: fileLocale, title } = record;
const pageLocale = getLocale();
const localeText =
fileLocale && fileLocale !== pageLocale
? new Intl.DisplayNames([pageLocale], { type: 'language' }).of(fileLocale)
: '';
const format = file.format.toUpperCase();
const size = prettyBytes(file.size, { locale: pageLocale });
const metaText = [localeText, format, size].filter(Boolean).join(', ');
---

<a
href={getFileHref(record)}
hreflang={fileLocale}
download={title}
type={file.mimeType}
{...props}
>
<span class="title"><slot>{title}</slot></span>
({metaText})
<Icon class="icon" name="download" /></a
>

<style>
/* Underline only the title, so the meta data doesn't obscure the link visually,
but is still part of the link content for assistive technology */
a {
display: contents;
}
.title {
text-decoration: inherit;
}
</style>
18 changes: 18 additions & 0 deletions src/components/LinkToFile/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Link to File

**Download link for a file, with accessible meta data (language, format and size).**

## Features

- Triggers a file download forced by the `a[download]` attribute. It uses the file's original name (or the one from the customised file `slug` field) rather than the auto-generated URL from DatoCMS. This feature relies on [file proxy redirects](../../../docs/routing.md#file-redirects), as the [`download` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download) only works on same-domain URLs.
- Shows language of the file contents (when different from the current page language) for better accessibility. Displayed in brackets as link suffix. Example: `(French)` on an English page.
- Shows the file format so users understand the link doesn't navigate to another page, but rather downloads a file. This improves accessibility and over user experience. Example `(PDF)`.
- Shows the file size in human readable. This allows users to avoid unnecessary downloads, which is also eco-responsible (green) best practice. Example: `(2.5 MB)`.
- Has icon to visually distinguish downloads. Can easily be removed or replaced with another (format specific) icon.
- Adds locale (`a[hreflang]`) and mime type (`a[type]`) for assistive technology and other applications. Example: `<a href="..." hreflang="fr" type="application/pdf">`.

## Relevant links

- [Orange.com: a11y guidelines for download links](https://a11y-guidelines.orange.com/en/articles/download-links/)
- [Accessibilly.com: Proposal for a more accessible Download Link
](https://accessabilly.com/proposal-for-a-more-accessible-download-link/)
8 changes: 7 additions & 1 deletion src/components/LinkToRecord/LinkToRecord.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { SiteLocale } from '@lib/datocms/types';
import { getLocale } from '@lib/i18n';
import { getHref, type RecordRoute } from '@lib/routing';
import Link from '@components/Link/Link.astro';
import LinkToFile from '@components/LinkToFile/LinkToFile.astro';
export type Props = {
openInNewTab?: boolean;
Expand All @@ -12,6 +13,11 @@ export type Props = {
const { record, openInNewTab, ...props } = Astro.props;
const locale = getLocale() as SiteLocale;
const href = getHref({ locale, record });
const isType = (type: string) => record.__typename === type;
---

<Link {href} {openInNewTab} {...props}><slot>{record.title}</slot></Link>
{
(isType('FileRecord'))
? <LinkToFile { record } {...props}><slot>{record.title}</slot></LinkToFile>
: <Link {href} {openInNewTab} {...props}><slot>{record.title}</slot></Link>
}
2 changes: 2 additions & 0 deletions src/lib/datocms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { DATOCMS_READONLY_API_TOKEN, HEAD_START_PREVIEW } from 'astro:env/server

const wait = (milliSeconds: number) => new Promise((resolve) => setTimeout(resolve, milliSeconds));

export const datocmsAssetsOrigin = 'https://www.datocms-assets.com/';

type DatocmsRequest = {
query: DocumentNode;
variables?: { [key: string]: string };
Expand Down
13 changes: 13 additions & 0 deletions src/lib/routing/FileRoute.fragment.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
fragment FileRoute on FileRecord {
id
file {
basename
filename
format
mimeType
size
url
}
locale
title
}
12 changes: 10 additions & 2 deletions src/lib/routing/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
import type { HomeRouteFragment, PageRouteFragment, SiteLocale } from '@lib/datocms/types';
import type { FileRouteFragment, HomeRouteFragment, PageRouteFragment, SiteLocale } from '@lib/datocms/types';
import { datocmsAssetsOrigin } from '@lib/datocms';
import { getLocale } from '@lib/i18n';

import { getPagePath } from './page';

export type RecordRoute =
| FileRouteFragment
| HomeRouteFragment
| PageRouteFragment;

export const getFileHref = (record: FileRouteFragment) => {
return record.file.url.replace(datocmsAssetsOrigin, '/files/');
};

export const getHomeHref = ({ locale = getLocale() } = {}) => {
return `/${locale}/`;
};
Expand All @@ -21,6 +26,9 @@ export const getHref = (
if (!record) {
return homeUrl;
}
if (record.__typename === 'FileRecord') {
return getFileHref(record);
}
if (record.__typename === 'HomePageRecord') {
return homeUrl;
}
Expand Down
Loading

0 comments on commit d4bef8b

Please sign in to comment.