Skip to content

Inactivate known external links when needed #873

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
27 changes: 23 additions & 4 deletions src/components/ContentNode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
-->

<script>
import { shouldInactivateRef } from 'docc-render/utils/references';
import AppStore from 'docc-render/stores/AppStore';
import referencesProvider from 'docc-render/mixins/referencesProvider';
import Aside from './ContentNode/Aside.vue';
import CodeListing from './ContentNode/CodeListing.vue';
Expand Down Expand Up @@ -118,9 +120,13 @@ const TabNavigatorVerticalThreshold = 7;
// and any of its children by mapping each node `type` to a given Vue component
//
// Note: A plain string of text is returned for nodes with `type="text"`
function renderNode(createElement, references) {
function renderNode(createElement, context = {}) {
const {
includedArchiveIdentifiers = [],
references = {},
} = context;
const renderChildren = children => children.map(
renderNode(createElement, references),
renderNode(createElement, context),
);

const renderListItems = items => items.map(item => (
Expand Down Expand Up @@ -465,12 +471,14 @@ function renderNode(createElement, references) {
const titleInlineContent = node.overridingTitleInlineContent
|| reference.titleInlineContent;
const titlePlainText = node.overridingTitle || reference.title;
const isActive = node.isActive ?? true;
const isInactive = shouldInactivateRef(node.identifier, { includedArchiveIdentifiers });
return createElement(Reference, {
props: {
url: reference.url,
kind: reference.kind,
role: reference.role,
isActive: node.isActive,
isActive: isActive && !isInactive,
ideTitle: reference.ideTitle,
titleStyle: reference.titleStyle,
hasInlineFormatting: !!titleInlineContent,
Expand Down Expand Up @@ -521,11 +529,14 @@ export default {
name: 'ContentNode',
constants: { TableHeaderStyle, TableColumnAlignments },
mixins: [referencesProvider],
data: () => ({
appState: AppStore.state,
}),
render: function render(createElement) {
// Dynamically map each content item and any children to their
// corresponding components, and wrap the whole tree in a <div>
return createElement(this.tag, { class: 'content' }, (
this.content.map(renderNode(createElement, this.references), this)
this.content.map(renderNode(createElement, this.context), this)
));
},
props: {
Expand Down Expand Up @@ -691,6 +702,14 @@ export default {
return text;
}, '').trim();
},
includedArchiveIdentifiers: ({ appState }) => appState.includedArchiveIdentifiers,
context: ({
includedArchiveIdentifiers = [],
references = {},
}) => ({
includedArchiveIdentifiers,
references,
}),
},
BlockType,
InlineType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,25 @@

<script>
// This component renders token text as a link to a given type.
import { shouldInactivateRef } from 'docc-render/utils/references';
import AppStore from 'docc-render/stores/AppStore';
import Reference from 'docc-render/components/ContentNode/Reference.vue';
import referencesProvider from 'docc-render/mixins/referencesProvider';

export default {
name: 'LinkableToken',
mixins: [referencesProvider],
data: () => ({
appState: AppStore.state,
}),
render(createElement) {
const reference = this.references[this.identifier];

const { includedArchiveIdentifiers } = this;
const isInactive = shouldInactivateRef(this.identifier, { includedArchiveIdentifiers });

// internal and external link
if (reference && reference.url) {
if (reference && reference.url && !isInactive) {
return createElement(Reference, {
props: {
url: reference.url,
Expand All @@ -30,11 +39,14 @@ export default {
this.$slots.default
));
}
// unresolved link, use span tag
// unresolved/inactive link, use span tag
return createElement('span', {}, (
this.$slots.default
));
},
computed: {
includedArchiveIdentifiers: ({ appState }) => appState.includedArchiveIdentifiers,
},
props: {
identifier: {
type: String,
Expand Down
8 changes: 7 additions & 1 deletion src/components/Navigator/NavigatorDataProvider.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
<script>
import { fetchIndexPathsData } from 'docc-render/utils/data';
import { flattenNestedData } from 'docc-render/utils/navigatorData';
import AppStore from 'docc-render/stores/AppStore';
import Language from 'docc-render/constants/Language';

/**
Expand Down Expand Up @@ -88,11 +89,16 @@ export default {
async fetchIndexData() {
try {
this.isFetching = true;
const { interfaceLanguages, references } = await fetchIndexPathsData(
const {
includedArchiveIdentifiers = [],
interfaceLanguages,
references,
} = await fetchIndexPathsData(
{ slug: this.$route.params.locale || '' },
);
this.navigationIndex = Object.freeze(interfaceLanguages);
this.navigationReferences = Object.freeze(references);
AppStore.setIncludedArchiveIdentifiers(includedArchiveIdentifiers);
} catch (e) {
this.errorFetching = true;
} finally {
Expand Down
5 changes: 5 additions & 0 deletions src/stores/AppStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ export default {
supportsAutoColorScheme,
systemColorScheme: ColorScheme.light,
availableLocales: [],
includedArchiveIdentifiers: [],
},
reset() {
this.state.imageLoadingStrategy = process.env.VUE_APP_TARGET === 'ide'
? ImageLoadingStrategy.eager : ImageLoadingStrategy.lazy;
this.state.preferredColorScheme = Settings.preferredColorScheme || defaultColorScheme;
this.state.supportsAutoColorScheme = supportsAutoColorScheme;
this.state.systemColorScheme = ColorScheme.light;
this.state.includedArchiveIdentifiers = [];
},
setImageLoadingStrategy(strategy) {
this.state.imageLoadingStrategy = strategy;
Expand All @@ -59,6 +61,9 @@ export default {
setSystemColorScheme(value) {
this.state.systemColorScheme = value;
},
setIncludedArchiveIdentifiers(value) {
this.state.includedArchiveIdentifiers = value;
},
syncPreferredColorScheme() {
if (!!Settings.preferredColorScheme
&& Settings.preferredColorScheme !== this.state.preferredColorScheme) {
Expand Down
19 changes: 19 additions & 0 deletions src/utils/references.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* This source file is part of the Swift.org open source project
*
* Copyright (c) 2024 Apple Inc. and the Swift project authors
* Licensed under Apache License v2.0 with Runtime Library Exception
*
* See https://swift.org/LICENSE.txt for license information
* See https://swift.org/CONTRIBUTORS.txt for Swift project authors
*/

// eslint-disable-next-line import/prefer-default-export
export function shouldInactivateRef(identifier, context = {}) {
const { includedArchiveIdentifiers = [] } = context;
return !!(includedArchiveIdentifiers.length && (
!includedArchiveIdentifiers.some(id => (
identifier?.startsWith(`doc://${id}`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Do we need to worry about capitalization here?
  2. I think we need to check if the identifier starts with doc://${id}/documentation. Just to make sure that the id matches exactly. (For example, Foo and FooBar)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Capitalization would be matched exactly as-is here. I don't think we would want to be less strict since we're given the exact identifier.
  2. That would only work for documentation pages and not tutorial pages. We could make this more strict, but I think checking for the special scheme and bundle ID should be sufficient (@d-ronnqvist correct me if I'm wrong)

))
));
}
51 changes: 51 additions & 0 deletions tests/unit/components/ContentNode.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1297,6 +1297,57 @@ describe('ContentNode', () => {
const reference = wrapper.find('.content');
expect(reference.isEmpty()).toBe(true);
});

it('inactivates references when `includedArchiveIdentifiers` is present and does not include the identifier', () => {
const id = {
bar: 'doc://Foo/documentation/foo/bar',
baz: 'doc://Baz/documentation/baz',
};
const wrapper = mountWithItem({
type: 'reference',
identifier: id.bar,
}, {
[id.bar]: {
title: 'bar',
url: '/documentation/foo/bar',
},
});

// active by default
let reference = wrapper.find(Reference);
expect(reference.exists()).toBe(true);
expect(reference.props('isActive')).toBe(true);

// inactive when `includedArchiveIdentifiers` is non-empty and does not include this id
wrapper.setData({
appState: {
includedArchiveIdentifiers: ['Baz'],
},
});
reference = wrapper.find(Reference);
expect(reference.exists()).toBe(true);
expect(reference.props('isActive')).toBe(false);

// active when `includedArchiveIdentifiers` is non-empty and includes this id
wrapper.setData({
appState: {
includedArchiveIdentifiers: ['Baz', 'Foo'],
},
});
reference = wrapper.find(Reference);
expect(reference.exists()).toBe(true);
expect(reference.props('isActive')).toBe(true);

// still active when `includedArchiveIdentifiers` is empty (for backwards compatibility)
wrapper.setData({
appState: {
includedArchiveIdentifiers: [],
},
});
reference = wrapper.find(Reference);
expect(reference.exists()).toBe(true);
expect(reference.props('isActive')).toBe(true);
});
});

describe('with type="strong"', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,49 @@ describe('LinkableToken', () => {
expect(link.props('url')).toBe(foo.url);
expect(link.text()).toBe(foo.title);
});

it('renders a span for inactive references', () => {
const bar = {
identifier: 'doc://Foo/documentation/foo/bar',
title: 'Bar',
url: '/documentation/foo/bar',
};
const references = { [bar.identifier]: bar };
const wrapper = shallowMount(LinkableToken, {
propsData: { identifier: bar.identifier },
slots: { default: bar.title },
provide: {
store: {
state: { references },
},
},
});

// active by default
let reference = wrapper.find(Reference);
expect(reference.exists()).toBe(true);
expect(reference.props('url')).toBe(bar.url);
expect(reference.text()).toBe(bar.title);

// inactive when `includedArchiveIdentifiers` is non-empty and does not include id
wrapper.setData({
appState: {
includedArchiveIdentifiers: ['Baz'],
},
});
reference = wrapper.find(Reference);
expect(reference.exists()).toBe(false);
expect(wrapper.text()).toBe(bar.title);

// active when `includedArchiveIdentifiers` is non-empty and includes id
wrapper.setData({
appState: {
includedArchiveIdentifiers: ['Baz', 'Foo'],
},
});
reference = wrapper.find(Reference);
expect(reference.exists()).toBe(true);
expect(reference.props('url')).toBe(bar.url);
expect(reference.text()).toBe(bar.title);
});
});
19 changes: 19 additions & 0 deletions tests/unit/components/Navigator/NavigatorDataProvider.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import NavigatorDataProvider from '@/components/Navigator/NavigatorDataProvider.vue';
import { shallowMount } from '@vue/test-utils';
import AppStore from 'docc-render/stores/AppStore';
import Language from 'docc-render/constants/Language';
import { TopicTypes } from '@/constants/TopicTypes';
import { fetchIndexPathsData } from '@/utils/data';
Expand Down Expand Up @@ -127,7 +128,13 @@ const references = {
foo: { bar: 'bar' },
};

const includedArchiveIdentifiers = [
'foo',
'bar',
];

const response = {
includedArchiveIdentifiers,
interfaceLanguages: {
[Language.swift.key.url]: [
swiftIndexOne,
Expand Down Expand Up @@ -174,6 +181,10 @@ describe('NavigatorDataProvider', () => {
jest.clearAllMocks();
});

afterEach(() => {
AppStore.reset();
});

it('fetches data when mounting NavigatorDataProvider', async () => {
expect(fetchIndexPathsData).toHaveBeenCalledTimes(0);
createWrapper();
Expand Down Expand Up @@ -552,4 +563,12 @@ describe('NavigatorDataProvider', () => {
},
]);
});

it('sets `includedArchiveIdentifiers` state in the app store', async () => {
expect(AppStore.state.includedArchiveIdentifiers).toEqual([]);
fetchIndexPathsData.mockResolvedValue(response);
createWrapper();
await flushPromises();
expect(AppStore.state.includedArchiveIdentifiers).toEqual(includedArchiveIdentifiers);
});
});
12 changes: 12 additions & 0 deletions tests/unit/stores/AppStore.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ describe('AppStore', () => {
systemColorScheme: ColorScheme.light,
preferredLocale: null,
availableLocales: [],
includedArchiveIdentifiers: [],
});
});

Expand All @@ -37,6 +38,7 @@ describe('AppStore', () => {
systemColorScheme: ColorScheme.light,
preferredLocale: null,
availableLocales: [],
includedArchiveIdentifiers: [],
});

// restore target
Expand Down Expand Up @@ -72,11 +74,20 @@ describe('AppStore', () => {
});
});

describe('setIncludedArchiveIdentifiers', () => {
it('sets the included archive identifiers', () => {
const includedArchiveIdentifiers = ['foo', 'bar'];
AppStore.setIncludedArchiveIdentifiers(includedArchiveIdentifiers);
expect(AppStore.state.includedArchiveIdentifiers).toEqual(includedArchiveIdentifiers);
});
});

it('resets the state', () => {
AppStore.setImageLoadingStrategy(ImageLoadingStrategy.eager);
AppStore.setPreferredColorScheme(ColorScheme.auto);
AppStore.setSystemColorScheme(ColorScheme.dark);
AppStore.syncPreferredColorScheme();
AppStore.setIncludedArchiveIdentifiers(['a']);
AppStore.reset();

expect(AppStore.state).toEqual({
Expand All @@ -86,6 +97,7 @@ describe('AppStore', () => {
systemColorScheme: ColorScheme.light,
preferredLocale: null,
availableLocales: [],
includedArchiveIdentifiers: [],
});
});
});
Loading